esp-modkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE +21 -0
  4. data/README.md +117 -0
  5. data/docs/architecture.md +125 -0
  6. data/docs/authoring-guide.md +206 -0
  7. data/docs/getting-started.md +183 -0
  8. data/docs/reference/api/active-project.md +22 -0
  9. data/docs/reference/api/agent.md +24 -0
  10. data/docs/reference/api/docs-generator.md +20 -0
  11. data/docs/reference/api/http-server.md +46 -0
  12. data/docs/reference/api/index.md +38 -0
  13. data/docs/reference/api/introspection.md +17 -0
  14. data/docs/reference/api/mcp-installer.md +26 -0
  15. data/docs/reference/api/mcp-server.md +27 -0
  16. data/docs/reference/api/mw-builder.md +14 -0
  17. data/docs/reference/api/mw-data-files.md +20 -0
  18. data/docs/reference/api/mw-dialogue-dsl.md +58 -0
  19. data/docs/reference/api/mw-i18n.md +20 -0
  20. data/docs/reference/api/mw-linter.md +18 -0
  21. data/docs/reference/api/mw-loader.md +26 -0
  22. data/docs/reference/api/mw-openmw-config.md +15 -0
  23. data/docs/reference/api/mw-operations.md +24 -0
  24. data/docs/reference/api/mw-preflight.md +17 -0
  25. data/docs/reference/api/mw-reference-index.md +21 -0
  26. data/docs/reference/api/mw-scaffolder.md +13 -0
  27. data/docs/reference/api/mw-script-blob.md +31 -0
  28. data/docs/reference/api/mw-script-extractor.md +17 -0
  29. data/docs/reference/api/operations.md +25 -0
  30. data/docs/reference/api/plugins.md +24 -0
  31. data/docs/reference/api/preferences.md +13 -0
  32. data/docs/reference/api/project-marker.md +23 -0
  33. data/docs/reference/api/providers.md +22 -0
  34. data/docs/reference/api/recents.md +17 -0
  35. data/docs/reference/api/ui.md +21 -0
  36. data/docs/reference/api/vcs.md +17 -0
  37. data/docs/reference/api/watcher.md +11 -0
  38. data/docs/reference/commands.md +271 -0
  39. data/docs/walkthrough.md +193 -0
  40. data/exe/esp +10 -0
  41. data/lib/esp/active_project.rb +71 -0
  42. data/lib/esp/agent.rb +104 -0
  43. data/lib/esp/cli/docs.rb +44 -0
  44. data/lib/esp/cli/i18n.rb +67 -0
  45. data/lib/esp/cli/mcp.rb +52 -0
  46. data/lib/esp/cli/plugins.rb +42 -0
  47. data/lib/esp/cli/refs.rb +137 -0
  48. data/lib/esp/cli/support.rb +87 -0
  49. data/lib/esp/cli.rb +317 -0
  50. data/lib/esp/docs_generator.rb +148 -0
  51. data/lib/esp/http_server.rb +232 -0
  52. data/lib/esp/introspection.rb +151 -0
  53. data/lib/esp/mcp_installer.rb +122 -0
  54. data/lib/esp/mcp_server.rb +465 -0
  55. data/lib/esp/mw/builder.rb +71 -0
  56. data/lib/esp/mw/data_files.rb +67 -0
  57. data/lib/esp/mw/dialogue_dsl.rb +209 -0
  58. data/lib/esp/mw/i18n.rb +113 -0
  59. data/lib/esp/mw/linter.rb +103 -0
  60. data/lib/esp/mw/loader.rb +130 -0
  61. data/lib/esp/mw/openmw_config.rb +138 -0
  62. data/lib/esp/mw/operations.rb +374 -0
  63. data/lib/esp/mw/preflight.rb +161 -0
  64. data/lib/esp/mw/reference_index.rb +182 -0
  65. data/lib/esp/mw/scaffolder.rb +197 -0
  66. data/lib/esp/mw/script_blob.rb +87 -0
  67. data/lib/esp/mw/script_extractor.rb +85 -0
  68. data/lib/esp/mw/tes3conv.rb +38 -0
  69. data/lib/esp/operations.rb +285 -0
  70. data/lib/esp/plugins.rb +75 -0
  71. data/lib/esp/preferences.rb +63 -0
  72. data/lib/esp/project_marker.rb +99 -0
  73. data/lib/esp/providers/anthropic.rb +74 -0
  74. data/lib/esp/providers/ollama.rb +102 -0
  75. data/lib/esp/providers/openai.rb +91 -0
  76. data/lib/esp/providers.rb +76 -0
  77. data/lib/esp/recents.rb +74 -0
  78. data/lib/esp/ui.rb +144 -0
  79. data/lib/esp/vcs.rb +112 -0
  80. data/lib/esp/version.rb +11 -0
  81. data/lib/esp/watcher.rb +55 -0
  82. data/lib/esp.rb +85 -0
  83. data/locales/en.yml +164 -0
  84. data/locales/fr.yml +10 -0
  85. metadata +241 -0
@@ -0,0 +1,374 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+
4
+ module Esp
5
+ module Mw
6
+ # The Morrowind plugin's half of the service layer. Same contract as
7
+ # Esp::Operations (string-keyed params hash in, plain Hash out) — these
8
+ # are the ops that decode TES3 records, drive tes3conv, or otherwise know
9
+ # what a "mod" is. Frontends never call this module directly; they route
10
+ # through Esp::Operations.dispatch(op, input).
11
+ #
12
+ # Every op resolves the project root from `Esp::ActiveProject.resolve`
13
+ # (step 23.5 slice 1) so an `open_project` request actually moves where
14
+ # build/lint/etc. operate — they no longer silently target Esp::ROOT.
15
+ # Precedence: explicit `root:` in params > active project > Esp::ROOT.
16
+ #
17
+ # InputError is aliased from the shell so plugin ops raise the same class
18
+ # the frontends rescue. Loader / Preflight / Tes3conv error classes are
19
+ # registered with Esp::Operations::ERROR_CODES at the bottom of this file
20
+ # — they couldn't be in the shell table because the plugin hadn't loaded
21
+ # yet when that table was built.
22
+ module Operations
23
+ InputError = Esp::Operations::InputError
24
+
25
+ PLUGIN_EXT = /\.(esp|esm|omwaddon)\z/i
26
+
27
+ class << self
28
+ def build(params)
29
+ mod = require_mod(params)
30
+ build_one(mod, params['locale'], root: project_root(params))
31
+ end
32
+
33
+ # Build every mod under mods/. Returns one entry per mod, same shape
34
+ # as a single build, so an agent can fan out without N calls.
35
+ def build_all(params)
36
+ root = project_root(params)
37
+ results = Esp::Mw::Builder.discover_mods(root: root).map do |mod|
38
+ build_one(mod, params['locale'], root: root)
39
+ end
40
+ { results: results }
41
+ end
42
+
43
+ # Import an existing plugin into source. `plugin` is either a
44
+ # filesystem path or, if it's a bare name (no path separator), a
45
+ # plugin name resolved against the OpenMW config's installed plugins.
46
+ # `name` defaults to the resolved file's basename. Source mod lands
47
+ # under the active project's mods/, not the toolchain repo's.
48
+ def unpack(params)
49
+ plugin = params['plugin']
50
+ raise InputError, Esp.t('errors.operations.missing_field', field: 'plugin') if blank?(plugin)
51
+
52
+ source = resolve_plugin(plugin, params)
53
+ raise InputError, Esp.t('errors.operations.plugin_not_found', plugin: plugin) unless source
54
+
55
+ root = project_root(params)
56
+ name = params['name'] || File.basename(source).sub(PLUGIN_EXT, '')
57
+ dst = File.join(root, 'mods', name, "#{name}.json")
58
+ FileUtils.mkdir_p(File.dirname(dst))
59
+ Esp::Mw::Tes3conv.convert(source, dst)
60
+ { plugin: plugin, source: source, mod: name, output: Esp::Operations.relative(dst, root: root) }
61
+ end
62
+
63
+ # Make a built dist/<mod>.esp available to a game engine. Two
64
+ # additive install paths (compose freely):
65
+ #
66
+ # - **OpenMW** (default): register the project's dist/ + the
67
+ # plugin with openmw.cfg via `data=` + `content=`. Idempotent —
68
+ # an already-present line reports `added: false`. Skipped when
69
+ # `params['register_openmw']` is explicitly false (e.g. an
70
+ # original-engine user who only wants the copy).
71
+ # - **Copy to a Data Files dir** (original engine, step 23.5
72
+ # slice 4): pass `params['copy_to']` with a path, or
73
+ # `params['to_data_files'] => true` to auto-resolve the user's
74
+ # vanilla Morrowind Data Files dir via Esp::Mw::DataFiles. The
75
+ # copy is "always overwrite" so a rebuild swaps the live plugin
76
+ # without the user touching anything.
77
+ #
78
+ # `data=` always points at the active project's dist/, so OpenMW
79
+ # picks up the build output from wherever the project lives.
80
+ def install(params)
81
+ mod = require_mod(params)
82
+ root = project_root(params)
83
+ esp = File.join(root, 'dist', "#{mod}.esp")
84
+ unless File.exist?(esp)
85
+ raise InputError,
86
+ Esp.t('errors.operations.build_first', esp: Esp::Operations.relative(esp, root: root))
87
+ end
88
+
89
+ payload = { mod: mod }
90
+ payload.merge!(register_openmw(mod, root, params)) unless params['register_openmw'] == false
91
+
92
+ if (target = copy_target(params))
93
+ payload[:copied_to] = copy_built_plugin(esp, mod, target)
94
+ end
95
+
96
+ payload
97
+ end
98
+
99
+ # List plugins installed in the OpenMW config's data directories,
100
+ # with active flag + load order. `config` overrides the cfg path.
101
+ def plugins_list(params = {})
102
+ cfg = openmw_config(params)
103
+ { openmw_cfg: cfg.path, exists: cfg.exist?, plugins: cfg.installed_plugins }
104
+ end
105
+
106
+ def i18n_check(params)
107
+ mod = require_mod(params)
108
+ source = Esp::Mw::Loader.resolve(mod, root: project_root(params))
109
+ { mod: mod, default_locale: Esp::Mw::I18n::DEFAULT_LOCALE,
110
+ locales: Esp::Mw::I18n.check(File.dirname(source)) }
111
+ end
112
+
113
+ # Read a mod's records as structured data. Format-agnostic — runs the
114
+ # source through the loader, so .rb/.py/.js/.ts all read back too.
115
+ def records_read(params)
116
+ mod = require_mod(params)
117
+ root = project_root(params)
118
+ source = Esp::Mw::Loader.resolve(mod, root: root)
119
+ records = Esp::Mw::Loader.load(source)
120
+ { mod: mod, source: Esp::Operations.relative(source, root: root),
121
+ count: records.size, records: records }
122
+ end
123
+
124
+ # Upsert one record into a mod's .json source, keyed by (type, id).
125
+ # JSON only: polyglot sources are programs, not editable data, so we
126
+ # refuse rather than silently no-op. Header is matched by type alone
127
+ # (one per file); every other record needs an id.
128
+ def record_write(params)
129
+ mod = require_mod(params)
130
+ record = params['record']
131
+ unless record.is_a?(Hash)
132
+ raise InputError,
133
+ Esp.t('errors.operations.missing_object', field: 'record')
134
+ end
135
+
136
+ type = record['type']
137
+ raise InputError, Esp.t('errors.operations.record_needs_type') if blank?(type)
138
+ if type != 'Header' && blank?(record['id'])
139
+ raise InputError, Esp.t('errors.operations.record_needs_id')
140
+ end
141
+
142
+ root = project_root(params)
143
+ source = json_source!(mod, root: root)
144
+ records = JSON.parse(File.read(source))
145
+ action, index = upsert(records, record)
146
+ File.write(source, "#{JSON.pretty_generate(records)}\n")
147
+ { mod: mod, source: Esp::Operations.relative(source, root: root), action: action,
148
+ type: type, id: record['id'], index: index }
149
+ end
150
+
151
+ # Author dialogue from a JSON spec (the data-driven analog of the Ruby
152
+ # `dialogue { }` DSL) into a mod's .json source. A topic is written
153
+ # wholesale: any existing DIAL/INFO records for the same topics are
154
+ # replaced, so re-authoring never leaves orphan info records.
155
+ def dialogue_write(params)
156
+ mod = require_mod(params)
157
+ spec = params['spec']
158
+ unless spec.is_a?(Hash) || spec.is_a?(Array)
159
+ raise InputError, Esp.t('errors.operations.missing_object', field: 'spec')
160
+ end
161
+
162
+ built = Esp::Mw::DialogueDsl.from_spec(spec)
163
+ topics = built.select { |r| r['type'] == 'Dialogue' }.map { |r| r['id'] }
164
+ root = project_root(params)
165
+ source = json_source!(mod, root: root)
166
+ records = strip_dialogue(JSON.parse(File.read(source)), topics).concat(built)
167
+ File.write(source, "#{JSON.pretty_generate(records)}\n")
168
+ { mod: mod, source: Esp::Operations.relative(source, root: root), topics: topics,
169
+ records_written: built.size, records: built }
170
+ end
171
+
172
+ def lint(params)
173
+ mod = require_mod(params)
174
+ index = require_index!
175
+ records = Esp::Mw::Loader.load(Esp::Mw::Loader.resolve(mod, root: project_root(params)))
176
+ issues = Esp::Mw::Linter.new(records, index).issues
177
+ {
178
+ mod: mod,
179
+ errors: issues.count { |i| i.severity == :error },
180
+ warnings: issues.count { |i| i.severity == :warning },
181
+ issues: issues.map(&:to_h)
182
+ }
183
+ end
184
+
185
+ def scaffold(params)
186
+ mod = require_mod(params)
187
+ root = project_root(params)
188
+ result = Esp::Mw::Scaffolder.create(
189
+ mod,
190
+ format: params['format'] || Esp::Mw::Scaffolder::DEFAULT_FORMAT,
191
+ author: params['author'],
192
+ description: params['description'],
193
+ force: params['force'] || false,
194
+ root: root
195
+ )
196
+ { mod: result.mod, format: result.format,
197
+ source: Esp::Operations.relative(result.source, root: root),
198
+ readme: Esp::Operations.relative(result.readme, root: root) }
199
+ end
200
+
201
+ def extract_scripts(params)
202
+ mod = require_mod(params)
203
+ result = Esp::Mw::ScriptExtractor.extract!(mod, root: project_root(params))
204
+ { mod: mod, extracted: result.extracted, skipped: result.skipped }
205
+ end
206
+
207
+ def refs_find(params)
208
+ index = require_index!
209
+ criteria = { query: params['q'], type: params['type'],
210
+ like: params['like'], exact: params['exact'] }
211
+ rows = index.find(**criteria, limit: (params['limit'] || 100).to_i)
212
+ total = index.count_matching(**criteria)
213
+ if params['show']
214
+ records = rows.map { |r| index.fetch_record(r['source_esm'], r['record_index']) }
215
+ { records: records, count: records.size, total: total }
216
+ else
217
+ { matches: rows, count: rows.size, total: total }
218
+ end
219
+ end
220
+
221
+ # Map a batch of reference ids → record type via the SQLite reference
222
+ # index. Backs the cell-view marker colouring (step 21 slice 3) — one
223
+ # call resolves all of a cell's references in a single SQL.
224
+ def refs_resolve(params)
225
+ index = require_index!
226
+ ids = Array(params['ids']).map(&:to_s)
227
+ { types: index.types_for(ids) }
228
+ end
229
+
230
+ private
231
+
232
+ # The project root every op resolves against. Same precedence chain
233
+ # used by diff/approve/reject via Esp::Operations#vcs_root, so build
234
+ # and diff target the same tree by default.
235
+ def project_root(params)
236
+ Esp::ActiveProject.resolve(params)
237
+ end
238
+
239
+ def build_one(mod, locale, root:)
240
+ result = Esp::Mw::Builder.build(mod, root: root, locale: locale)
241
+ { mod: mod, output: Esp::Operations.relative(result.output, root: root), logs: result.logs }
242
+ end
243
+
244
+ def blank?(value)
245
+ value.nil? || value.to_s.empty?
246
+ end
247
+
248
+ # Resolve a mod's source and insist it's JSON.
249
+ def json_source!(mod, root:)
250
+ source = Esp::Mw::Loader.resolve(mod, root: root)
251
+ ext = File.extname(source)
252
+ return source if ext == '.json'
253
+
254
+ raise InputError, Esp.t('errors.operations.json_only', file: File.basename(source), ext: ext)
255
+ end
256
+
257
+ # Replace the matching record in place, else append. Returns
258
+ # [:updated | :inserted, index].
259
+ def upsert(records, record)
260
+ idx = records.index { |r| same_record?(r, record) }
261
+ if idx
262
+ records[idx] = record
263
+ [:updated, idx]
264
+ else
265
+ records << record
266
+ [:inserted, records.size - 1]
267
+ end
268
+ end
269
+
270
+ def same_record?(existing, record)
271
+ return false unless existing['type'] == record['type']
272
+ return true if record['type'] == 'Header'
273
+
274
+ existing['id'] == record['id']
275
+ end
276
+
277
+ # Drop the DIAL record for each named topic plus its INFO records
278
+ # (id "<topic>_<n>"), so a re-authored topic replaces cleanly.
279
+ def strip_dialogue(records, topics)
280
+ patterns = topics.map { |n| /\A#{Regexp.escape(n)}_\d+\z/ }
281
+ records.reject do |r|
282
+ (r['type'] == 'Dialogue' && topics.include?(r['id'])) ||
283
+ (r['type'] == 'DialogueInfo' && patterns.any? { |p| p.match?(r['id'].to_s) })
284
+ end
285
+ end
286
+
287
+ def register_openmw(mod, root, params)
288
+ cfg = openmw_config(params)
289
+ raise InputError, Esp.t('errors.operations.no_cfg', path: cfg.path) unless cfg.exist?
290
+
291
+ lines = [%(data="#{File.join(root, 'dist')}"), "content=#{mod}.esp"]
292
+ cfg.backup_once_per_day
293
+ actions = lines.map { |line| { line: line, added: cfg.append(line) } }
294
+ { openmw_cfg: cfg.path, actions: actions }
295
+ end
296
+
297
+ # Decide which directory to copy the built plugin into:
298
+ # - `copy_to` (explicit path) wins.
299
+ # - `to_data_files: true` (or "true" string) auto-resolves the
300
+ # vanilla Data Files dir via Esp::Mw::DataFiles.resolve —
301
+ # honours $MORROWIND_DATA, then the per-OS Steam/GOG defaults
302
+ # (the same detector `refs unpack` already uses).
303
+ # Returns the resolved path, or nil if neither was requested.
304
+ # Raises InputError when to_data_files is asked but the auto-
305
+ # detected path doesn't exist (no Morrowind install on this box).
306
+ def copy_target(params)
307
+ explicit = params['copy_to']
308
+ return File.expand_path(explicit) if explicit && !explicit.to_s.empty?
309
+
310
+ truthy = params['to_data_files']
311
+ return nil unless [true, 'true'].include?(truthy)
312
+
313
+ dest = Esp::Mw::DataFiles.resolve
314
+ raise InputError, Esp.t('errors.operations.no_data_files', path: dest) unless File.directory?(dest)
315
+
316
+ dest
317
+ end
318
+
319
+ def copy_built_plugin(esp, mod, target)
320
+ FileUtils.mkdir_p(target)
321
+ dst = File.join(target, "#{mod}.esp")
322
+ FileUtils.cp(esp, dst)
323
+ dst
324
+ end
325
+
326
+ def openmw_config(params)
327
+ path = params['config']
328
+ path.nil? || path.to_s.empty? ? Esp::Mw::OpenmwConfig.new : Esp::Mw::OpenmwConfig.new(path)
329
+ end
330
+
331
+ # A real path wins; otherwise a bare name (no separator) is matched
332
+ # against installed plugins by filename, with or without extension.
333
+ def resolve_plugin(plugin, params)
334
+ return plugin if File.exist?(plugin)
335
+ return nil if plugin.include?('/') || plugin.include?('\\')
336
+
337
+ base = File.basename(plugin)
338
+ stem = base.sub(PLUGIN_EXT, '')
339
+ match = openmw_config(params).installed_plugins.find do |p|
340
+ p[:name].casecmp?(base) || p[:name].sub(PLUGIN_EXT, '').casecmp?(stem)
341
+ end
342
+ match && match[:path]
343
+ end
344
+
345
+ def require_mod(params)
346
+ mod = params['mod']
347
+ raise InputError, Esp.t('errors.operations.missing_field', field: 'mod') if blank?(mod)
348
+
349
+ mod
350
+ end
351
+
352
+ def require_index!
353
+ index = Esp::Mw::ReferenceIndex.new
354
+ raise InputError, Esp.t('errors.no_index') unless File.exist?(index.db_path)
355
+
356
+ index
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end
362
+
363
+ # Plugin error classes — extend the shell's caller-errors table so both
364
+ # frontends rescue them automatically. Done after the module body so the
365
+ # constant references resolve.
366
+ Esp::Operations.register_error(Esp::Mw::Loader::LoadError, 'load_error')
367
+ Esp::Operations.register_error(Esp::Mw::Preflight::ValidationError, 'validation_error')
368
+ Esp::Operations.register_error(Esp::Mw::Tes3conv::ConvertFailed, 'tes3conv_failed')
369
+ Esp::Operations.register_error(Esp::Mw::Tes3conv::NotFound, 'tes3conv_not_found')
370
+
371
+ # Announce this plugin to the registry. 'mw' is the id that lands in a
372
+ # project's .esp/project.json `game:` field; Esp::Operations.dispatch
373
+ # routes game ops here for projects with that game.
374
+ Esp::Plugins.register('mw', label: 'Morrowind', operations: Esp::Mw::Operations)
@@ -0,0 +1,161 @@
1
+ require 'json'
2
+
3
+ module Esp
4
+ module Mw
5
+ # Resolves text_source into inline text and regenerates Script-record
6
+ # subrecords (SCHD header + SCVR vars + SCDT bytecode placeholder) into
7
+ # the form tes3conv expects. Mutates records in place.
8
+ #
9
+ # Validation rules:
10
+ # - Source must be pure ASCII (MWScript's compiler is silent on UTF-8).
11
+ # - Variable identifiers must be <= 20 chars (MWScript truncates ~20-23).
12
+ # - Warn (don't fail) when a script's `Begin <name>` doesn't match its
13
+ # record id; vanilla often disagrees so this is intentionally soft.
14
+ module Preflight
15
+ class ValidationError < StandardError; end
16
+
17
+ VAR_DECL_RE = /^\s*(short|long|float)\s+([A-Za-z_]\w*)\b/i
18
+ BEGIN_RE = /^\s*begin\s+([A-Za-z_]\w*)\b/i
19
+ NAME_LIMIT = 20
20
+
21
+ # Walks records and processes Script + Cell entries. Returns a list
22
+ # of log lines (info + warnings) for the caller to print.
23
+ def self.process!(records, source_dir:)
24
+ logs = []
25
+ plugin_ids = collect_plugin_ids(records)
26
+ records.each do |record|
27
+ case record['type']
28
+ when 'Script' then logs.concat(process_script!(record, source_dir))
29
+ when 'Cell' then logs.concat(process_cell!(record, plugin_ids))
30
+ end
31
+ end
32
+ logs
33
+ end
34
+
35
+ # Defaults missing `mast_index` on cell references. tes3conv requires
36
+ # the field on every reference; the convention is `0` for plugin-local
37
+ # records and N>0 for master records (1 = first listed master, etc.).
38
+ # We auto-fill 0 when the referenced id is defined in *this* plugin —
39
+ # the common case for new content — and emit a clear error otherwise
40
+ # so the user doesn't see tes3conv's cryptic "missing field" message.
41
+ def self.process_cell!(record, plugin_ids)
42
+ cell_label = describe_cell(record)
43
+ logs = []
44
+ Array(record['references']).each_with_index do |ref, i|
45
+ next if ref.key?('mast_index')
46
+
47
+ ref_id = ref['id'].to_s
48
+ if plugin_ids.include?(ref_id.downcase)
49
+ ref['mast_index'] = 0
50
+ logs << "#{cell_label}: ref[#{i}] #{ref_id.inspect} — defaulted mast_index to 0 (plugin-local)"
51
+ else
52
+ raise ValidationError, Esp.t('errors.preflight.master_ref_needs_mast_index',
53
+ cell: cell_label, id: ref_id)
54
+ end
55
+ end
56
+ logs
57
+ end
58
+
59
+ # Records this plugin defines (anything with a top-level `id`).
60
+ # Lowercased so the lookup is case-insensitive, matching how the
61
+ # engine and linter treat TES3 ids.
62
+ def self.collect_plugin_ids(records)
63
+ records.filter_map { |r| r['id']&.to_s&.downcase }.to_set
64
+ end
65
+
66
+ def self.describe_cell(record)
67
+ name = record['name'].to_s
68
+ grid = record.dig('data', 'grid')
69
+ return "cell #{name.inspect}" unless name.empty?
70
+ return "exterior cell #{grid.inspect}" if grid
71
+
72
+ 'cell <unknown>'
73
+ end
74
+
75
+ def self.process_script!(record, source_dir)
76
+ sid = record['id'] || '<unknown>'
77
+ text, label = resolve_text(record, source_dir)
78
+ return ["#{sid}: empty text, skipping"] if text.empty?
79
+
80
+ validate_ascii!(text, label)
81
+ shorts, longs, floats, begin_name = parse_variables(text, label)
82
+ logs = []
83
+ if begin_name && begin_name.downcase != sid.downcase
84
+ logs << "#{sid}: WARNING — Begin name #{begin_name.inspect} does not match record id"
85
+ end
86
+ apply_script_data!(record, text, shorts, longs, floats)
87
+ logs
88
+ end
89
+
90
+ def self.apply_script_data!(record, text, shorts, longs, floats)
91
+ vars_blob, vars_length = Esp::Mw::ScriptBlob.variables(shorts + longs + floats)
92
+ record['header'] = {
93
+ 'num_shorts' => shorts.size,
94
+ 'num_longs' => longs.size,
95
+ 'num_floats' => floats.size,
96
+ 'bytecode_length' => 0,
97
+ 'variables_length' => vars_length
98
+ }
99
+ record['variables'] = vars_blob
100
+ record['bytecode'] = Esp::Mw::ScriptBlob.empty_bytecode
101
+ record['text'] = text
102
+ record.delete('text_source')
103
+ end
104
+
105
+ def self.resolve_text(record, source_dir)
106
+ ts = record['text_source']
107
+ return [record['text'].to_s, "<inline text for #{record['id'] || '?'}>"] unless ts
108
+
109
+ path = File.expand_path(ts, source_dir)
110
+ unless File.file?(path)
111
+ raise ValidationError,
112
+ Esp.t('errors.preflight.text_source_missing', path: path)
113
+ end
114
+
115
+ [File.read(path), path]
116
+ end
117
+
118
+ def self.validate_ascii!(text, label)
119
+ text.each_char.with_index do |c, i|
120
+ next if c.ord < 128
121
+
122
+ prefix = text[0, i]
123
+ line = prefix.count("\n") + 1
124
+ col = i - (prefix.rindex("\n") || -1)
125
+ hex = format('%04X', c.ord)
126
+ raise ValidationError, Esp.t('errors.preflight.non_ascii',
127
+ label: label, char: c.inspect, hex: hex, line: line, col: col)
128
+ end
129
+ end
130
+
131
+ def self.parse_variables(text, label)
132
+ shorts = []
133
+ longs = []
134
+ floats = []
135
+ begin_name = nil
136
+ text.each_line do |line|
137
+ stripped = line.lstrip
138
+ next if stripped.start_with?(';')
139
+
140
+ if (m = BEGIN_RE.match(stripped))
141
+ begin_name = m[1]
142
+ next
143
+ end
144
+ next unless (m = VAR_DECL_RE.match(line))
145
+
146
+ kind = m[1].downcase
147
+ name = m[2]
148
+ if name.length > NAME_LIMIT
149
+ raise ValidationError, Esp.t('errors.preflight.var_too_long', label: label,
150
+ name: name.inspect,
151
+ length: name.length,
152
+ limit: NAME_LIMIT)
153
+ end
154
+ bucket = { 'short' => shorts, 'long' => longs, 'float' => floats }[kind]
155
+ bucket << name
156
+ end
157
+ [shorts, longs, floats, begin_name]
158
+ end
159
+ end
160
+ end
161
+ end