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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +21 -0
- data/README.md +117 -0
- data/docs/architecture.md +125 -0
- data/docs/authoring-guide.md +206 -0
- data/docs/getting-started.md +183 -0
- data/docs/reference/api/active-project.md +22 -0
- data/docs/reference/api/agent.md +24 -0
- data/docs/reference/api/docs-generator.md +20 -0
- data/docs/reference/api/http-server.md +46 -0
- data/docs/reference/api/index.md +38 -0
- data/docs/reference/api/introspection.md +17 -0
- data/docs/reference/api/mcp-installer.md +26 -0
- data/docs/reference/api/mcp-server.md +27 -0
- data/docs/reference/api/mw-builder.md +14 -0
- data/docs/reference/api/mw-data-files.md +20 -0
- data/docs/reference/api/mw-dialogue-dsl.md +58 -0
- data/docs/reference/api/mw-i18n.md +20 -0
- data/docs/reference/api/mw-linter.md +18 -0
- data/docs/reference/api/mw-loader.md +26 -0
- data/docs/reference/api/mw-openmw-config.md +15 -0
- data/docs/reference/api/mw-operations.md +24 -0
- data/docs/reference/api/mw-preflight.md +17 -0
- data/docs/reference/api/mw-reference-index.md +21 -0
- data/docs/reference/api/mw-scaffolder.md +13 -0
- data/docs/reference/api/mw-script-blob.md +31 -0
- data/docs/reference/api/mw-script-extractor.md +17 -0
- data/docs/reference/api/operations.md +25 -0
- data/docs/reference/api/plugins.md +24 -0
- data/docs/reference/api/preferences.md +13 -0
- data/docs/reference/api/project-marker.md +23 -0
- data/docs/reference/api/providers.md +22 -0
- data/docs/reference/api/recents.md +17 -0
- data/docs/reference/api/ui.md +21 -0
- data/docs/reference/api/vcs.md +17 -0
- data/docs/reference/api/watcher.md +11 -0
- data/docs/reference/commands.md +271 -0
- data/docs/walkthrough.md +193 -0
- data/exe/esp +10 -0
- data/lib/esp/active_project.rb +71 -0
- data/lib/esp/agent.rb +104 -0
- data/lib/esp/cli/docs.rb +44 -0
- data/lib/esp/cli/i18n.rb +67 -0
- data/lib/esp/cli/mcp.rb +52 -0
- data/lib/esp/cli/plugins.rb +42 -0
- data/lib/esp/cli/refs.rb +137 -0
- data/lib/esp/cli/support.rb +87 -0
- data/lib/esp/cli.rb +317 -0
- data/lib/esp/docs_generator.rb +148 -0
- data/lib/esp/http_server.rb +232 -0
- data/lib/esp/introspection.rb +151 -0
- data/lib/esp/mcp_installer.rb +122 -0
- data/lib/esp/mcp_server.rb +465 -0
- data/lib/esp/mw/builder.rb +71 -0
- data/lib/esp/mw/data_files.rb +67 -0
- data/lib/esp/mw/dialogue_dsl.rb +209 -0
- data/lib/esp/mw/i18n.rb +113 -0
- data/lib/esp/mw/linter.rb +103 -0
- data/lib/esp/mw/loader.rb +130 -0
- data/lib/esp/mw/openmw_config.rb +138 -0
- data/lib/esp/mw/operations.rb +374 -0
- data/lib/esp/mw/preflight.rb +161 -0
- data/lib/esp/mw/reference_index.rb +182 -0
- data/lib/esp/mw/scaffolder.rb +197 -0
- data/lib/esp/mw/script_blob.rb +87 -0
- data/lib/esp/mw/script_extractor.rb +85 -0
- data/lib/esp/mw/tes3conv.rb +38 -0
- data/lib/esp/operations.rb +285 -0
- data/lib/esp/plugins.rb +75 -0
- data/lib/esp/preferences.rb +63 -0
- data/lib/esp/project_marker.rb +99 -0
- data/lib/esp/providers/anthropic.rb +74 -0
- data/lib/esp/providers/ollama.rb +102 -0
- data/lib/esp/providers/openai.rb +91 -0
- data/lib/esp/providers.rb +76 -0
- data/lib/esp/recents.rb +74 -0
- data/lib/esp/ui.rb +144 -0
- data/lib/esp/vcs.rb +112 -0
- data/lib/esp/version.rb +11 -0
- data/lib/esp/watcher.rb +55 -0
- data/lib/esp.rb +85 -0
- data/locales/en.yml +164 -0
- data/locales/fr.yml +10 -0
- 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
|