Compare commits

...

2 commits

Author SHA1 Message Date
825bd3a354 Merge branch 'main' into 'main'
deterministically locate a lot of UI strings + UI scale fix

See merge request DazedAnon/DazedMTLTool!39
2026-07-20 17:00:17 +00:00
no
03c8952ac8 deterministically locate a lot of UI strings + UI scale fix 2026-07-12 14:20:02 +03:00

View file

@ -19,8 +19,18 @@
* - Dialogue / choices / scroll text -> www/data/MapXXX.json,
* CommonEvents.json, Troops.json (by identity-matching the running event
* list, so duplicate strings are never confused).
* - Scenario-plugin dialogue -> scenario/Scenario.json and similar
* (plugin scenario engines such as Nore/"Tes" that keep command-lists in one
* big JSON dictionary keyed by scene name, loaded into $dataScenario). Both
* in-game location and "Locate in Files" cover these.
* - Pictures and other images -> img/... path + the event command
* (or JS call stack) that loaded them.
* - Plugin / menu UI text (Bitmap.drawText / drawTextEx) -> where the VALUE
* itself lives whenever it can be resolved: a TextManager vocab assignment
* in a plugin .js, a System.json term, a database name/description, a
* notetag <tag:value>, or a plugins.js parameter. Strings built with
* String.prototype.format ("%1 remaining") are traced back to their
* template. The draw call site is kept as secondary info.
*
* Press the hotkey (default F10) to open the inspector overlay. Each row has an
* "Open in VSCode" button (runs `code -g file:line`) and an **edit** button to
@ -84,12 +94,16 @@
if (!isNaN(n) && n > 0) { return Math.max(0.75, Math.min(3, n)); }
}
var base = 816;
var gameW = (typeof Graphics !== 'undefined' && Graphics.width) ? Graphics.width : 0;
var viewW = window.innerWidth || document.documentElement.clientWidth || base;
var w = Math.max(gameW || base, viewW);
var scale = w / base;
// The overlay lives in WINDOW pixels, so scale from the window size only.
// Using the game's internal resolution made a 1920px-wide game in a ~1366px
// window zoom 2.35x, with the 460px panel swallowing >80% of the screen.
var scale = viewW / base;
var dpr = window.devicePixelRatio || 1;
if (dpr > 1.15) { scale *= Math.min(2, 0.75 + dpr * 0.35); }
// Whatever the math says, keep the panel under ~45% of the window
// (the >=1 floor below still wins for small windows).
scale = Math.min(scale, (viewW * 0.45) / 460);
return Math.max(1, Math.min(2.75, scale));
}
@ -168,6 +182,41 @@
return path ? path.join(DATA_DIR, name) : DATA_DIR + '/' + name;
}
// Scenario systems (e.g. the Nore / "Tes" engine) keep event command-lists OUTSIDE
// Map/CommonEvents/Troops: one big JSON dictionary under scenario/, keyed by scene
// name, loaded into a global ($dataScenario) and run via Game_Interpreter.setupChild.
// Each value is a plain MZ command list, so once we know the file + key it resolves
// to a line just like any other data file.
var SCENARIO_DIR = 'scenario';
var SCENARIO_SETS = [
{ global: '$dataScenario', file: 'Scenario.json' },
{ global: '$dataScenario_en', file: 'Scenario_en.json' }
];
function scenarioPath(fileName) {
return path ? path.join(WWW_ROOT, SCENARIO_DIR, fileName)
: WWW_ROOT + '/' + SCENARIO_DIR + '/' + fileName;
}
// Every *.json actually present in the scenario/ folder (empty for normal games).
function scenarioFiles() {
var out = [];
if (!nodeOk) { return out; }
try {
fs.readdirSync(scenarioPath('')).forEach(function (f) {
if (/\.json$/i.test(f)) { out.push(scenarioPath(f)); }
});
} catch (e) { /* no scenario/ folder -> normal for most games */ }
return out;
}
// If a scenario file's dictionary is already parsed into a known global, return the
// global name so callers can reuse it instead of re-parsing a (often huge) file.
function scenarioGlobalForFile(file) {
var base = baseName(file).toLowerCase();
for (var i = 0; i < SCENARIO_SETS.length; i++) {
if (SCENARIO_SETS[i].file.toLowerCase() === base) { return SCENARIO_SETS[i].global; }
}
return null;
}
function log() {
if (window.console) { console.log.apply(console, ['[TLInspector]'].concat([].slice.call(arguments))); }
}
@ -290,15 +339,43 @@
return skipWs(t, i);
}
// File drifted under a structural path (e.g. the scenario JSON was re-exported while
// the game held an older parse, shifting command indices): find the exact quoted
// value instead, preferring the occurrence nearest the path's top-level container so
// duplicate lines in other scenes/events don't win.
function reanchorByText(c, pathArr, quoted) {
var hits = [], p = c.text.indexOf(quoted), CAP = 5000;
while (p >= 0 && hits.length < CAP) { hits.push(p); p = c.text.indexOf(quoted, p + quoted.length); }
if (!hits.length) { return null; }
if (hits.length === 1) { return offsetToLineCol(c, hits[0]); }
var anchor = locateOffset(c.text, pathArr.slice(0, 1)); // start of the scene / list
var best = hits[0];
if (anchor >= 0) {
var bd = Math.abs(hits[0] - anchor);
for (var k = 1; k < hits.length; k++) { var dd = Math.abs(hits[k] - anchor); if (dd < bd) { bd = dd; best = hits[k]; } }
}
return offsetToLineCol(c, best);
}
var lineCache = {}; // file::pathKey::mtime -> {line,col}
function locateLine(file, pathArr) {
function locateLine(file, pathArr, expectedText) {
if (!file || !pathArr || !nodeOk) { return null; }
var c = readFileCached(file);
if (!c) { return null; }
var key = file + '::' + pathArr.join('/') + '::' + c.mtime;
if (lineCache[key]) { return lineCache[key]; }
if (lineCache.hasOwnProperty(key)) { return lineCache[key]; }
var off = locateOffset(c.text, pathArr);
var res = off >= 0 ? offsetToLineCol(c, off) : null;
var quoted = (expectedText != null && expectedText !== '') ? JSON.stringify(String(expectedText)) : null;
var res;
if (off >= 0 && (!quoted || c.text.substr(off, quoted.length) === quoted)) {
res = offsetToLineCol(c, off); // path still valid (verified against the text)
} else if (quoted) {
// Path drifted (or the text isn't where the path points) -> re-anchor by exact
// text; if that finds nothing, still fall back to the structural offset.
res = reanchorByText(c, pathArr, quoted) || (off >= 0 ? offsetToLineCol(c, off) : null);
} else {
res = off >= 0 ? offsetToLineCol(c, off) : null;
}
lineCache[key] = res;
return res;
}
@ -345,6 +422,18 @@
}
}
}
// Scenario dictionaries: the running list is a value of $dataScenario keyed
// by scene name (setupChild runs the list by reference, so identity holds).
for (var si = 0; si < SCENARIO_SETS.length; si++) {
var sg = SCENARIO_SETS[si], dict = window[sg.global];
if (!dict || typeof dict !== 'object') { continue; }
for (var sk in dict) {
if (dict.hasOwnProperty(sk) && dict[sk] === list) {
return { file: scenarioPath(sg.file), prefix: [sk],
label: sg.file.replace(/\.json$/i, '') + ':' + sk };
}
}
}
} catch (e) { log('resolveList error', e); }
return null;
}
@ -535,8 +624,23 @@
var i = findByKey(key);
if (i >= 0) { seenExisting(i, -1); return; }
var site = firstUserFrame(new Error().stack);
textRecords.push({ kind: 'ui', text: text, file: site.file, prefix: null,
tail: null, label: site.label, uiLine: site.line, group: -1, ts: Date.now(), _key: key });
var rec = { kind: 'ui', text: text, file: site.file, prefix: null,
tail: null, label: site.label, uiLine: site.line, uiCol: null,
group: -1, ts: Date.now(), _key: key };
// Prefer the VALUE's own home (vocab assignment / term / notetag / parameter)
// over the draw call site; keep the site as secondary info.
try {
var src = resolveUiSource(text);
if (src) {
rec.srcKind = src.kind; rec.label = src.label; rec.expText = src.expText;
if (src.file) {
rec.file = src.file; rec.uiLine = src.line || null; rec.uiCol = src.col || null;
rec.tail = src.tail || null;
rec.siteFile = site.file; rec.siteLine = site.line; rec.siteLabel = site.label;
}
}
} catch (e) { log('uiSource', e); }
textRecords.push(rec);
while (textRecords.length > CFG.historySize) { textRecords.shift(); }
scheduleRefresh();
}
@ -554,6 +658,363 @@
return { file: null, line: null, label: '(unknown)' };
}
//=========================================================================
// UI text provenance: map a drawn string back to the VALUE's own home
// (TextManager vocab assignment, System.json term, database field, notetag
// meta value, or plugins.js parameter) instead of just the draw call site.
// Index is built lazily from live game state; invalidated on F9 reload.
//=========================================================================
var uiValueIndex = null, uiIndexBuiltAt = 0;
function uiIndexAdd(map, value, cand) {
if (typeof value !== 'string') { return; }
if (value.replace(/\s/g, '').length < 2) { return; } // same floor as capture
(map[value] || (map[value] = [])).push(cand);
}
function buildUiValueIndex() {
// null prototype: game text can legitimately be 'constructor'/'toString'…
var map = Object.create(null);
// TextManager vocab: plugin-assigned own DATA properties. (The engine's own
// terms are accessor properties reading $dataSystem.terms -> indexed below.)
try {
if (typeof TextManager !== 'undefined') {
Object.getOwnPropertyNames(TextManager).forEach(function (p) {
var d = Object.getOwnPropertyDescriptor(TextManager, p);
if (d && !d.get && typeof d.value === 'string') {
uiIndexAdd(map, d.value, { t: 'tm', prop: p });
}
});
}
} catch (e) { log('uiIndex tm', e); }
// System.json: terms, type-name lists, and a few top-level strings.
try {
if (typeof $dataSystem !== 'undefined' && $dataSystem) {
var sysFile = dataFile('System.json');
var terms = $dataSystem.terms || {};
['basic', 'commands', 'params'].forEach(function (g) {
(terms[g] || []).forEach(function (v, i) {
uiIndexAdd(map, v, { t: 'json', file: sysFile, path: ['terms', g, i],
label: 'System terms.' + g + '[' + i + ']' });
});
});
var msgs = terms.messages || {};
Object.keys(msgs).forEach(function (k) {
uiIndexAdd(map, msgs[k], { t: 'json', file: sysFile, path: ['terms', 'messages', k],
label: 'System terms.messages.' + k });
});
['elements', 'skillTypes', 'weaponTypes', 'armorTypes', 'equipTypes'].forEach(function (g) {
($dataSystem[g] || []).forEach(function (v, i) {
uiIndexAdd(map, v, { t: 'json', file: sysFile, path: [g, i],
label: 'System ' + g + '[' + i + ']' });
});
});
uiIndexAdd(map, $dataSystem.gameTitle, { t: 'json', file: sysFile, path: ['gameTitle'], label: 'System gameTitle' });
uiIndexAdd(map, $dataSystem.currencyUnit, { t: 'json', file: sysFile, path: ['currencyUnit'], label: 'System currencyUnit' });
}
} catch (e) { log('uiIndex system', e); }
// Database display fields + notetag meta values. A meta value resolves to the
// object's "note" line; locateMetaValue narrows to the value inside the tag.
[
['$dataActors', 'Actors.json', ['name', 'nickname', 'profile']],
['$dataClasses', 'Classes.json', ['name']],
['$dataSkills', 'Skills.json', ['name', 'description', 'message1', 'message2']],
['$dataItems', 'Items.json', ['name', 'description']],
['$dataWeapons', 'Weapons.json', ['name', 'description']],
['$dataArmors', 'Armors.json', ['name', 'description']],
['$dataEnemies', 'Enemies.json', ['name']],
['$dataStates', 'States.json', ['name', 'message1', 'message2', 'message3', 'message4']]
].forEach(function (spec) {
try {
var arr = window[spec[0]];
if (!Array.isArray(arr)) { return; }
var file = dataFile(spec[1]), short = spec[1].replace(/\.json$/i, '');
for (var id = 1; id < arr.length; id++) {
var o = arr[id];
if (!o) { continue; }
spec[2].forEach(function (f) {
uiIndexAdd(map, o[f], { t: 'json', file: file, path: [id, f],
label: short + '[' + id + '].' + f });
});
if (o.meta) {
Object.keys(o.meta).forEach(function (mk) {
if (typeof o.meta[mk] === 'string') {
uiIndexAdd(map, o.meta[mk], { t: 'meta', file: file, path: [id, 'note'],
label: short + '[' + id + '] note <' + mk + ':…>' });
}
});
}
}
} catch (e) { log('uiIndex ' + spec[0], e); }
});
// Plugin parameters (plugins.js).
try {
if (window.$plugins) {
window.$plugins.forEach(function (pl) {
if (!pl || pl.status === false || !pl.parameters) { return; }
Object.keys(pl.parameters).forEach(function (pk) {
uiIndexAdd(map, pl.parameters[pk], { t: 'param', plugin: pl.name, param: pk });
});
});
}
} catch (e) { log('uiIndex params', e); }
return map;
}
function uiValueCandidates(text) {
if (!uiValueIndex) { uiValueIndex = buildUiValueIndex(); uiIndexBuiltAt = Date.now(); }
if (!jsLitIndex) { jsLitIndex = buildJsLitIndex(); }
var c = uiValueIndex[text] || null;
var lits = jsLitIndex[text] || null;
if (!c && !lits && Date.now() - uiIndexBuiltAt > 5000) {
// Vocab tables can be rewritten at runtime (language toggles): on a miss,
// rebuild against current state, at most once every 5s.
uiValueIndex = buildUiValueIndex(); uiIndexBuiltAt = Date.now();
c = uiValueIndex[text] || null;
}
if (lits) { c = (c || []).concat(lits); }
return c;
}
// Parse a JS string literal starting at t[i] (the quote) -> {value, end} or null.
function parseJsString(t, i) {
var q = t[i];
if (q !== '"' && q !== "'" && q !== '`') { return null; }
var out = '', j = i + 1;
while (j < t.length) {
var ch = t[j];
if (ch === '\\') {
var n = t[j + 1];
if (n === 'n') { out += '\n'; }
else if (n === 't') { out += '\t'; }
else if (n === 'r') { out += '\r'; }
else if (n === 'u') { out += String.fromCharCode(parseInt(t.substr(j + 2, 4), 16) || 0); j += 4; }
else if (n === 'x') { out += String.fromCharCode(parseInt(t.substr(j + 2, 2), 16) || 0); j += 2; }
else { out += n; }
j += 2; continue;
}
if (ch === q) { return { value: out, end: j + 1 }; }
if (ch === '\n' && q !== '`') { return null; } // unterminated
out += ch; j++;
}
return null;
}
// Plugin .js files in LOAD order (later plugins overwrite earlier assignments, so
// "last literal match" below = the assignment that actually won at runtime).
function pluginFilesInLoadOrder() {
var out = [];
if (!nodeOk) { return out; }
var pdir = path.join(WWW_ROOT, 'js', 'plugins');
try {
if (window.$plugins) {
window.$plugins.forEach(function (pl) {
if (pl && pl.status !== false && pl.name && pl.name !== 'TLInspector') {
out.push(path.join(pdir, pl.name + '.js'));
}
});
}
} catch (e) { /* ignore */ }
if (!out.length) {
try {
fs.readdirSync(pdir).forEach(function (f) {
if (/\.js$/.test(f) && f !== 'TLInspector.js') { out.push(path.join(pdir, f)); }
});
} catch (e) { /* ignore */ }
}
return out;
}
// Find the plugin-file assignment `TextManager.<prop> = '<literal>'` whose literal
// equals the property's CURRENT runtime value — this is what distinguishes an
// English vocab file from the Japanese one when both assign the same property.
// Falls back to the last assignment site in load order (value built at runtime).
var tmAssignCache = {}; // prop \0 value -> {file,line,col,exact} | null
function locateTextManagerAssignment(prop, value) {
var key = prop + '\u0000' + value;
if (tmAssignCache.hasOwnProperty(key)) { return tmAssignCache[key]; }
var safe = String(prop).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var re = new RegExp('TextManager\\s*(?:\\.\\s*' + safe +
'|\\[\\s*[\'"]' + safe + '[\'"]\\s*\\])\\s*=(?![=>])', 'g');
var literalHit = null, lastSite = null;
pluginFilesInLoadOrder().forEach(function (file) {
var c = readFileCached(file);
if (!c) { return; }
re.lastIndex = 0;
var m;
while ((m = re.exec(c.text))) {
if (inComment(c, m.index)) { continue; }
var vi = skipWs(c.text, m.index + m[0].length);
var site = { file: file, off: vi };
lastSite = site;
var lit = parseJsString(c.text, vi);
if (lit && lit.value === value) { literalHit = site; }
}
});
var hit = literalHit || lastSite, res = null;
if (hit) {
var lc = offsetToLineCol(readFileCached(hit.file), hit.off);
res = { file: hit.file, line: lc.line, col: lc.col, off: hit.off, exact: !!literalHit };
}
tmAssignCache[key] = res;
return res;
}
// Locate a plugin parameter value inside plugins.js (bounded to that plugin's entry).
function locatePluginParam(plugin, param, value) {
if (!nodeOk) { return null; }
var file = path.join(WWW_ROOT, 'js', 'plugins.js');
var c = readFileCached(file);
if (!c) { return null; }
var anchor = c.text.indexOf('"name":"' + plugin + '"');
if (anchor < 0) {
anchor = c.text.search(new RegExp('"name"\\s*:\\s*"' +
String(plugin).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"'));
}
if (anchor < 0) { return null; }
var next = c.text.indexOf('"name":', anchor + 7); // start of the NEXT plugin entry
var p = c.text.indexOf(JSON.stringify(String(value)), anchor);
var lc = offsetToLineCol(c, (p >= 0 && (next < 0 || p < next)) ? p : anchor);
return { file: file, line: lc.line, col: lc.col };
}
// Line/col of a notetag VALUE inside an object's "note" string. The JSON path
// points at the whole note; narrow to the meta value's escaped text within it.
function locateMetaValue(file, pathArr, value) {
if (!file || !pathArr || !nodeOk) { return null; }
var c = readFileCached(file);
if (!c) { return null; }
var key = file + '::meta:' + pathArr.join('/') + '::' + value + '::' + c.mtime;
if (lineCache.hasOwnProperty(key)) { return lineCache[key]; }
var res = null;
var off = locateOffset(c.text, pathArr);
if (off >= 0 && c.text[off] === '"') {
var end = scanString(c.text, off);
var esc = JSON.stringify(String(value)).slice(1, -1);
var p = esc ? c.text.indexOf(esc, off) : -1;
res = offsetToLineCol(c, (p >= 0 && p < end) ? p : off);
} else if (off >= 0) {
res = offsetToLineCol(c, off);
}
lineCache[key] = res;
return res;
}
// String literals hardcoded in plugin .js code — e.g. a medal/skill table defined
// as arrays right in a plugin ("Great Sage" in Nore_MedalParam.js). Lowest-priority
// source: a drawn string can coincidentally equal an unrelated code literal, so
// vocab/data/notetag/param sources always win over this. Built once, lazily
// (plugin files can't change without F5); dropped on F9 in case one was edited
// through the built-in editor.
var jsLitIndex = null;
var JSLIT_MAX_PER_VALUE = 8;
function buildJsLitIndex() {
// null prototype: plugin code is full of literals like 'constructor'/'toString'
var map = Object.create(null);
pluginFilesInLoadOrder().forEach(function (file) {
var c = readFileCached(file);
if (!c) { return; }
var t = c.text, i = 0, n = t.length;
while (i < n) {
var ch = t[i], nx = t[i + 1];
if (ch === '/' && nx === '/') { var e1 = t.indexOf('\n', i); i = e1 < 0 ? n : e1 + 1; continue; }
if (ch === '/' && nx === '*') { var e2 = t.indexOf('*/', i + 2); i = e2 < 0 ? n : e2 + 2; continue; }
if (ch === '"' || ch === "'" || ch === '`') {
var lit = parseJsString(t, i);
if (lit) {
var v = lit.value;
// same floors as maybeCaptureUiText: >=2 non-space chars + a real letter
if (v.replace(/\s/g, '').length >= 2 && /[^\s\d.,:%/+\-()]/.test(v)) {
var arr = map[v] || (map[v] = []);
if (arr.length < JSLIT_MAX_PER_VALUE) { arr.push({ t: 'jslit', file: file, off: i }); }
}
i = lit.end; continue;
}
}
i++;
}
});
return map;
}
// Recent String.prototype.format calls (template -> output), so a drawn string
// like "2 turn(s) remaining" resolves to its '%1 turn(s) remaining' template even
// though the drawn form never exists in any file.
var FORMAT_LOG = [], FORMAT_LOG_MAX = 40;
if (CFG.captureUiText && String.prototype.format) {
var _strFormat = String.prototype.format;
String.prototype.format = function () {
var out = _strFormat.apply(this, arguments);
try {
if (arguments.length && typeof out === 'string' && out.length >= 2) {
var tpl = String(this);
if (tpl !== out) {
var last = FORMAT_LOG[FORMAT_LOG.length - 1];
if (!last || last.out !== out || last.tpl !== tpl) {
FORMAT_LOG.push({ tpl: tpl, out: out });
if (FORMAT_LOG.length > FORMAT_LOG_MAX) { FORMAT_LOG.shift(); }
}
}
}
} catch (e) { /* ignore */ }
return out;
};
}
// Drawn string -> best value-source descriptor, or null if unknown.
function resolveUiSource(text) {
var val = text, via = '', cands = uiValueCandidates(text);
if (!cands) {
for (var i = FORMAT_LOG.length - 1; i >= 0; i--) {
if (FORMAT_LOG[i].out === text) {
cands = uiValueCandidates(FORMAT_LOG[i].tpl);
if (cands) { val = FORMAT_LOG[i].tpl; via = ' ← format()'; }
break;
}
}
}
if (!cands || !cands.length) { return null; }
var ORDER = { tm: 0, json: 1, meta: 2, param: 3, jslit: 4 };
var best = cands[0];
for (var k = 1; k < cands.length; k++) {
if (ORDER[cands[k].t] < ORDER[best.t]) { best = cands[k]; }
}
var loc = null;
if (best.t === 'tm') {
loc = locateTextManagerAssignment(best.prop, val);
// The assignment's own RHS literal is also in the jslit index — that's the
// SAME location, not an "other match": drop it before counting extras.
if (loc) {
cands = cands.filter(function (x) {
return !(x.t === 'jslit' && x.file === loc.file && x.off === loc.off);
});
}
}
var extra = cands.length - 1;
var suffix = via + (extra > 0 ? ' (+' + extra + ' other match' + (extra > 1 ? 'es' : '') + ')' : '');
if (best.t === 'tm') {
if (loc) {
return { kind: 'tm', file: loc.file, line: loc.line, col: loc.col,
label: 'TextManager.' + best.prop + suffix, expText: val };
}
return { kind: 'tm', file: null, expText: val,
label: 'TextManager.' + best.prop + ' (assignment not in plugin files)' + suffix };
}
if (best.t === 'param') {
var pp = locatePluginParam(best.plugin, best.param, val);
return { kind: 'param', file: pp && pp.file, line: pp && pp.line, col: pp && pp.col,
label: 'plugins.js ' + best.plugin + ' → ' + best.param + suffix, expText: val };
}
if (best.t === 'jslit') {
var cf = readFileCached(best.file);
if (!cf) { return null; }
var ll = offsetToLineCol(cf, best.off);
return { kind: 'jslit', file: best.file, line: ll.line, col: ll.col,
label: baseName(best.file) + ' string literal' + suffix, expText: val };
}
return { kind: best.t, file: best.file, tail: best.path, label: best.label + suffix, expText: val };
}
//=========================================================================
// Hooks: images (load log with call-site)
//=========================================================================
@ -912,8 +1373,8 @@
//=========================================================================
var dupIndex = null; // [{file, path, text}]
function collectMessages(absFile, fname, obj, emit) {
function walkList(list, prefix) {
// Emit every message / choice string in an MZ command list, tagged with its JSON path.
function walkCommandList(list, prefix, emit) {
if (!Array.isArray(list)) { return; }
for (var i = 0; i < list.length; i++) {
var cmd = list[i];
@ -929,20 +1390,31 @@
}
}
}
function collectMessages(absFile, fname, obj, emit) {
if (/^Map\d+/.test(fname) && obj && obj.events) {
obj.events.forEach(function (ev, e) {
if (!ev || !ev.pages) { return; }
ev.pages.forEach(function (pg, p) { walkList(pg.list, ['events', e, 'pages', p, 'list']); });
ev.pages.forEach(function (pg, p) { walkCommandList(pg.list, ['events', e, 'pages', p, 'list'], emit); });
});
} else if (fname === 'CommonEvents.json' && Array.isArray(obj)) {
obj.forEach(function (ce, c) { if (ce && ce.list) { walkList(ce.list, [c, 'list']); } });
obj.forEach(function (ce, c) { if (ce && ce.list) { walkCommandList(ce.list, [c, 'list'], emit); } });
} else if (fname === 'Troops.json' && Array.isArray(obj)) {
obj.forEach(function (tr, t) {
if (tr && tr.pages) { tr.pages.forEach(function (pg, p) { walkList(pg.list, [t, 'pages', p, 'list']); }); }
if (tr && tr.pages) { tr.pages.forEach(function (pg, p) { walkCommandList(pg.list, [t, 'pages', p, 'list'], emit); }); }
});
}
}
// Scenario dictionary { "scene name": [ ...command list... ], ... } -> each value is
// a command list whose JSON path is [sceneName, index, 'parameters', 0].
function collectScenarioMessages(dict, emit) {
if (!dict || typeof dict !== 'object') { return; }
Object.keys(dict).forEach(function (key) {
if (Array.isArray(dict[key])) { walkCommandList(dict[key], [key], emit); }
});
}
function buildDupIndex() {
if (dupIndex) { return dupIndex; }
dupIndex = [];
@ -961,6 +1433,20 @@
dupIndex.push({ file: abs, path: pathArr, text: text });
});
});
// Scenario dictionaries: reuse the already-parsed global (Scenario.json can be
// tens of MB) and only fall back to parsing the file if the global is absent.
scenarioFiles().forEach(function (abs) {
var gname = scenarioGlobalForFile(abs);
var dict = gname ? window[gname] : null;
if (!dict) {
var sc = readFileCached(abs);
if (!sc) { return; }
try { dict = JSON.parse(sc.text); } catch (e) { return; }
}
collectScenarioMessages(dict, function (pathArr, text) {
dupIndex.push({ file: abs, path: pathArr, text: text });
});
});
log('dup index built', dupIndex.length, 'messages');
} catch (e) { log('dup index', e); }
return dupIndex;
@ -1077,6 +1563,9 @@
})
.forEach(function (f) { _scanFiles.push(dataFile(f)); });
} catch (e) { /* ignore */ }
// Scenario dictionaries (plugin scenario systems) hold the bulk of dialogue in
// some games; without these "Locate in Files" can't find scenario-only text.
scenarioFiles().forEach(function (f) { _scanFiles.push(f); });
var jsDir = path.join(WWW_ROOT, 'js');
_scanFiles.push(path.join(jsDir, 'plugins.js'));
var pdir = path.join(jsDir, 'plugins');
@ -1129,6 +1618,9 @@
if (!nodeOk || !text) { return results; }
var escaped = JSON.stringify(text).slice(1, -1); // JSON-escaped form (\n etc.)
var variants = escaped === text ? [text] : [text, escaped]; // raw form + escaped, deduped
// As the text appears inside a single-quoted JS literal ('Let\'s go home').
var jsq = escaped.replace(/\\"/g, '"').replace(/'/g, "\\'");
if (variants.indexOf(jsq) < 0) { variants.push(jsq); }
pluginAndSystemFiles().forEach(function (file) {
var c = readFileCached(file);
if (!c) { return; }
@ -1145,9 +1637,11 @@
if (!embedded && !(isJs && inComment(c, pos))) {
var lc = offsetToLineCol(c, pos);
var k = file + ':' + lc.line;
// A complete JSON string value ("text") is a high-confidence
// match (e.g. a menu term) vs. text buried in a longer string.
var quoted = c.text[pos - 1] === '"' && c.text[pos + v.length] === '"';
// A complete string value ("text" in JSON; '…' / "…" / `…` in
// JS) is a high-confidence match vs. text buried in a longer one.
var qb = c.text[pos - 1], qa = c.text[pos + v.length];
var quoted = (qb === '"' && qa === '"') ||
(isJs && qb === qa && (qb === "'" || qb === '`'));
if (!seen[k]) { seen[k] = 1; results.push({ file: file, line: lc.line, col: lc.col, quoted: quoted }); }
}
pos = c.text.indexOf(v, pos + v.length);
@ -1165,6 +1659,14 @@
function searchFilesSmart(text) {
var r = searchFiles(text);
if (r.length) { return { results: r, query: text, partial: false }; }
// Numbers may be runtime-substituted into a %1/%2 template: "2 turn(s) left"
// never exists on disk while '%1 turn(s) left' does.
var n = 0;
var tpl = text.replace(/\d+(?:\.\d+)?/g, function () { return '%' + (++n); });
if (n > 0 && tpl !== text) {
var rt = searchFiles(tpl);
if (rt.length) { return { results: rt, query: tpl, partial: true }; }
}
var words = text.replace(/\n/g, ' ').split(/\s+/).filter(Boolean);
for (var n = words.length - 1; n >= 1; n--) {
var sub = words.slice(0, n).join(' ');
@ -1382,15 +1884,50 @@
return -1;
}
function createDoc(file) {
// Files above this size are NOT loaded whole into the <textarea>: a 68MB scenario
// file would push ~3M lines into the DOM and build a gutter string with millions of
// numbers, freezing then crashing the game. Instead we load a WINDOW of lines around
// the target and splice edits back into the full text on save (see saveEditor).
var BIG_FILE_BYTES = 3 * 1024 * 1024;
var WINDOW_BEFORE = 300, WINDOW_AFTER = 300;
// Slice a window of [WINDOW_BEFORE+WINDOW_AFTER] lines centred on targetLine out of the
// full text; fills winStart/winEnd/baseLine/text on the doc.
function windowDoc(doc, targetLine) {
var full = doc.fullText, tgt = (targetLine && targetLine > 0) ? targetLine : 1;
var startLine = Math.max(1, tgt - WINDOW_BEFORE);
var winStart = lineStartOffset(full, startLine);
var winEnd = winStart, n = 0, cap = WINDOW_BEFORE + WINDOW_AFTER;
while (n < cap) { var p = full.indexOf('\n', winEnd); if (p < 0) { winEnd = full.length; break; } winEnd = p + 1; n++; }
doc.baseLine = startLine; doc.winStart = winStart; doc.winEnd = winEnd;
doc.text = full.slice(winStart, winEnd);
}
function winLineCount(d) {
var c = 1, t = d.text; for (var i = 0; i < t.length; i++) { if (t.charCodeAt(i) === 10) { c++; } }
return c;
}
function lineInWindow(d, line) {
return d.windowed && line >= d.baseLine && line <= d.baseLine + winLineCount(d) - 1;
}
function createDoc(file, targetLine) {
var raw;
try { raw = fs.readFileSync(file, 'utf8'); }
catch (e) { toast('Could not read file'); log('read', e); return null; }
var bom = raw.charCodeAt(0) === 0xFEFF;
if (bom) { raw = raw.slice(1); }
var nl = /\r\n/.test(raw) ? '\r\n' : '\n'; // preserve the file's line endings
return { file: file, name: baseName(file), text: raw.replace(/\r\n/g, '\n'),
nl: nl, bom: bom, dirty: false, line: 0, scrollTop: 0, selStart: 0, selEnd: 0 };
var full = raw.replace(/\r\n/g, '\n');
var doc = { file: file, name: baseName(file), nl: nl, bom: bom, dirty: false,
line: 0, scrollTop: 0, selStart: 0, selEnd: 0, windowed: false, baseLine: 1 };
if (full.length > BIG_FILE_BYTES) {
doc.windowed = true;
doc.fullText = full;
windowDoc(doc, targetLine);
} else {
doc.text = full;
}
return doc;
}
function stashActive() {
@ -1480,10 +2017,11 @@
function refreshGutter() {
var v = editor.area.value, count = 1;
for (var i = 0; i < v.length; i++) { if (v.charCodeAt(i) === 10) { count++; } }
if (count === editor._lines) { return; }
editor._lines = count;
var d = activeDoc(), base = (d && d.windowed) ? d.baseLine : 1;
if (count === editor._lines && base === editor._gutterBase) { return; }
editor._lines = count; editor._gutterBase = base;
var s = '';
for (var k = 1; k <= count; k++) { s += k + '\n'; }
for (var k = 0; k < count; k++) { s += (base + k) + '\n'; }
editor.gutter.textContent = s;
syncGutter();
}
@ -1509,14 +2047,31 @@
return off;
}
// Actual rendered line height of the textarea. Under a CSS zoom the browser
// rounds every rendered line box to whole device pixels, so the nominal 18px is
// off by up to ~1% — across a 70k-line file that puts the viewport hundreds of
// lines away from the (correctly placed) cursor. Measure instead of trusting EDLH.
function editorLineHeight() {
var area = editor.area;
if (!area) { return EDLH; }
var v = area.value, lines = 1;
for (var i = 0; i < v.length; i++) { if (v.charCodeAt(i) === 10) { lines++; } }
var h = (area.scrollHeight - 16) / lines; // 16 = 8px top + bottom padding (CSS above)
return (h > EDLH / 2 && h < EDLH * 2) ? h : EDLH;
}
function jumpToLine(line) {
var d = activeDoc();
// In windowed mode `line` is a FILE line; translate to the textarea's local line.
var local = (d && d.windowed) ? (line - d.baseLine + 1) : line;
if (local < 1) { local = 1; }
var area = editor.area, text = area.value;
var so = lineStartOffset(text, line);
var so = lineStartOffset(text, local);
var eo = text.indexOf('\n', so); if (eo < 0) { eo = text.length; }
try { area.setSelectionRange(so, eo); } catch (e) { /* ignore */ }
area.scrollTop = Math.max(0, (line - 4) * EDLH);
area.scrollTop = Math.max(0, (local - 4) * editorLineHeight());
syncGutter();
updateEditorTitle(line);
updateEditorTitle(line); // status shows the real FILE line
}
// Entry point from openInEditor(): open (or focus) a tab for `file` and jump to line.
@ -1526,7 +2081,7 @@
editor.root.style.display = 'flex';
var i = docIndexByFile(file);
if (i < 0) {
var d = createDoc(file); if (!d) { return; }
var d = createDoc(file, line); if (!d) { return; }
stashActive();
editor.docs.push(d);
i = editor.docs.length - 1;
@ -1535,15 +2090,38 @@
renderTabs();
jumpToLine(line || 1);
editor.area.focus();
if (d.windowed) {
toast('Large file — showing lines ' + d.baseLine + '' +
(d.baseLine + winLineCount(d) - 1) + ' of ' + d.name + ' (partial view)');
}
} else {
var d2 = editor.docs[i];
// Re-open of a windowed file whose target is outside the loaded window: recut
// the window around the new line (unless there are unsaved edits to preserve).
if (d2.windowed && line && !lineInWindow(d2, line) && !d2.dirty) {
stashActive(); editor.active = i;
windowDoc(d2, line);
loadActive(); renderTabs(); jumpToLine(line); editor.area.focus();
} else {
selectTab(i, line || 1);
}
}
}
function saveEditor() {
var d = activeDoc(); if (!d) { return; }
d.text = editor.area.value;
var out = (d.nl === '\r\n') ? d.text.replace(/\n/g, '\r\n') : d.text;
// Windowed docs hold only a slice in the textarea: splice it back into the full
// text (and re-anchor the window to the edited slice's new extent) before writing.
var full;
if (d.windowed) {
full = d.fullText.slice(0, d.winStart) + d.text + d.fullText.slice(d.winEnd);
d.fullText = full;
d.winEnd = d.winStart + d.text.length;
} else {
full = d.text;
}
var out = (d.nl === '\r\n') ? full.replace(/\n/g, '\r\n') : full;
if (d.bom) { out = String.fromCharCode(0xFEFF) + out; }
try {
fs.writeFileSync(d.file, out, 'utf8');
@ -1554,7 +2132,7 @@
// re-fetch on-screen images and redraw the scene so the change is visible
// immediately without leaving the editor or restarting.
refreshGameElements();
toast('Saved ' + d.name + ' — reloaded in-game');
toast('Saved ' + d.name + (d.windowed ? ' (partial view)' : '') + ' — reloaded in-game');
} catch (e) { setEditorStatus('save failed'); toast('Save failed: ' + e.message); log('save', e); }
}
@ -1622,7 +2200,7 @@
var mt = f.matches[f.idx], area = editor.area;
try { area.setSelectionRange(mt.start, mt.end); } catch (e) { /* ignore */ }
var line = area.value.slice(0, mt.start).split('\n').length;
area.scrollTop = Math.max(0, (line - 4) * EDLH);
area.scrollTop = Math.max(0, (line - 4) * editorLineHeight());
syncGutter(); updateFindCount();
}
@ -1697,6 +2275,30 @@
} catch (e) { /* a plugin's onLoad hook may itself throw; don't abort the reload */ }
}
// Reload scenario dictionaries only when the file actually changed on disk. These can
// be tens of MB, so re-parsing on every reload (when nothing scenario-side changed)
// would stall F9. On first sight we adopt the current mtime WITHOUT re-parsing an
// already-loaded global, so an unchanged scenario never costs a big parse.
var _scenarioMtime = {};
function scenarioMtime(file) {
try { var st = fs.statSync(file); return st.mtimeMs || (st.mtime && st.mtime.getTime()) || 0; }
catch (e) { return -1; }
}
function reloadScenarioData() {
var n = 0;
SCENARIO_SETS.forEach(function (sg) {
if (typeof window[sg.global] === 'undefined') { return; } // game doesn't use it
var file = scenarioPath(sg.file), m = scenarioMtime(file);
if (m < 0) { return; }
var known = _scenarioMtime.hasOwnProperty(file);
if (!known) { _scenarioMtime[file] = m; if (window[sg.global]) { return; } }
if (known && _scenarioMtime[file] === m && window[sg.global]) { return; } // unchanged
try { window[sg.global] = JSON.parse(fs.readFileSync(file, 'utf8')); _scenarioMtime[file] = m; n++; }
catch (e) { /* mid-write or gone; keep the in-memory copy */ }
});
return n;
}
function reloadDataFiles() {
var n = 0;
for (var name in DATA_GLOBALS) {
@ -1709,6 +2311,7 @@
n++;
} catch (e) { /* file may be absent for this game; skip */ }
}
n += reloadScenarioData();
try {
if (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId()) {
// $dataMap must be assigned BEFORE onLoad: it keys off (object === $dataMap)
@ -1777,6 +2380,7 @@
if (!nodeOk) { toast('Reload needs Node (NW.js)'); return; }
// Drop our own caches so the panel relocates against the new file contents.
fileCache = {}; lineCache = {}; dupIndex = null; _scanFiles = null;
uiValueIndex = null; tmAssignCache = {}; jsLitIndex = null; // notetags / vocab / plugin edits
var parts = [];
var d = reloadDataFiles(); if (d) { parts.push(d + ' data file' + (d === 1 ? '' : 's')); }
@ -2039,9 +2643,14 @@
for (var i = textRecords.length - 1; i >= 0; i--) {
var r = textRecords[i];
if (!r.file) { resolveRecord(r); } // text-search fallback for identity misses
var loc = (r.file && r.tail) ? locateLine(r.file, r.tail) : null;
var expText = (r.expText != null) ? r.expText : r.text; // format(): locate the template, not the output
var loc = null;
if (r.file && r.tail) {
loc = (r.srcKind === 'meta') ? locateMetaValue(r.file, r.tail, expText)
: locateLine(r.file, r.tail, expText);
}
var line = loc ? loc.line : (r.uiLine || null);
var col = loc ? loc.col : null;
var col = loc ? loc.col : (r.uiCol || null);
var live = msgBusy && r.group === currentGroup && currentGroup !== -1;
var row = document.createElement('div');
@ -2057,6 +2666,9 @@
'<span class="tl-badge ' + r.kind + '">' + r.kind + '</span>' +
(live ? '<span class="tl-badge live">LIVE</span>' : '') +
'<span class="tl-loc">' + esc(locText) + '</span>' +
(r.srcKind && r.file ? '<span style="color:#8a9">' + esc(r.label) + '</span>' : '') +
(r.siteFile ? '<span class="tl-mini" data-act="site">drawn at ' +
esc((r.siteLabel || '').replace(/^.*\//, '')) + '</span>' : '') +
'<span class="tl-mini" data-act="dups">' +
(r.kind === 'ui' ? 'Locate In Files' : (r.file ? 'Find Duplicates' : 'Locate In Files')) + '</span>' +
'</div>' +
@ -2070,6 +2682,12 @@
else { toast('Unresolved: ' + rec.label); }
});
}
var siteBtn = rowEl.querySelector('[data-act="site"]');
if (siteBtn) {
siteBtn.addEventListener('click', function () {
openInEditor(rec.siteFile, rec.siteLine, null);
});
}
var dupBtn = rowEl.querySelector('[data-act="dups"]');
var dupsEl = rowEl.querySelector('.tl-dups');
if (dupBtn) {
@ -2107,7 +2725,7 @@
total = occ.length;
note = total + ' occurrence' + (total === 1 ? '' : 's') + (ui.ignoreNl ? ' (ignoring \\n)' : '');
shown = occ.slice(0, MAXSHOW).map(function (o) {
var l = locateLine(o.file, o.path);
var l = locateLine(o.file, o.path, o.text);
return { file: o.file, line: l && l.line, col: l && l.col,
isSelf: rec.file === o.file && JSON.stringify(rec.tail) === JSON.stringify(o.path) };
});
@ -2412,6 +3030,10 @@
close: function () { toggle(false); },
records: textRecords,
images: imageLog,
// debug/testing helpers
resolveUiSource: resolveUiSource, // drawn string -> value-source descriptor
locateMetaValue: locateMetaValue,
searchFilesSmart: searchFilesSmart,
cfg: CFG
};