diff --git a/util/tl_inspector/TLInspector.js b/util/tl_inspector/TLInspector.js index ba03d32..31d327f 100644 --- a/util/tl_inspector/TLInspector.js +++ b/util/tl_inspector/TLInspector.js @@ -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 , 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. = ''` 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,37 +1373,48 @@ //========================================================================= var dupIndex = null; // [{file, path, text}] - function collectMessages(absFile, fname, obj, emit) { - function walkList(list, prefix) { - if (!Array.isArray(list)) { return; } - for (var i = 0; i < list.length; i++) { - var cmd = list[i]; - if (!cmd) { continue; } - if (cmd.code === 401 || cmd.code === 405) { - if (cmd.parameters && typeof cmd.parameters[0] === 'string') { - emit(prefix.concat([i, 'parameters', 0]), cmd.parameters[0]); - } - } else if (cmd.code === 102 && cmd.parameters && Array.isArray(cmd.parameters[0])) { - cmd.parameters[0].forEach(function (ch, n) { - if (typeof ch === 'string') { emit(prefix.concat([i, 'parameters', 0, n]), ch); } - }); + // 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]; + if (!cmd) { continue; } + if (cmd.code === 401 || cmd.code === 405) { + if (cmd.parameters && typeof cmd.parameters[0] === 'string') { + emit(prefix.concat([i, 'parameters', 0]), cmd.parameters[0]); } + } else if (cmd.code === 102 && cmd.parameters && Array.isArray(cmd.parameters[0])) { + cmd.parameters[0].forEach(function (ch, n) { + if (typeof ch === 'string') { emit(prefix.concat([i, 'parameters', 0, n]), ch); } + }); } } + } + + 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