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,182 @@
1
+ require 'json'
2
+ require 'sqlite3'
3
+
4
+ module Esp
5
+ module Mw
6
+ # SQLite index over the unpacked vanilla ESM JSONs. Replaces "grep over
7
+ # 200MB of JSON" with a millisecond keyed lookup.
8
+ #
9
+ # Schema is intentionally narrow: just enough to answer "where is this
10
+ # record defined?" and "what's its full JSON?". Full-record retrieval
11
+ # falls back to reading the source ESM at the stored array index.
12
+ #
13
+ # Storage location (step 23.5 slice 2): vanilla data is *not* per-project
14
+ # — it's identical for every user, and ~150 MB of it. It lives at
15
+ # `$ESP_REFERENCES_DIR` (explicit override), else
16
+ # `$ESP_DATA_DIR/references/`, else `~/.config/esp/references/`. One
17
+ # index across every project. The defaults resolve at call time, not at
18
+ # require, so an env-var change between invocations takes effect.
19
+ class ReferenceIndex
20
+ class << self
21
+ # Where vanilla `<Master>.esm.json` files live. Explicit override
22
+ # via $ESP_REFERENCES_DIR wins; otherwise fall under the per-user
23
+ # data dir (Esp::Recents.data_dir handles the ESP_DATA_DIR /
24
+ # MW_DATA_DIR / ~/.config/esp resolution).
25
+ def default_source_dir
26
+ ENV['ESP_REFERENCES_DIR'] || File.join(Esp::Recents.data_dir, 'references')
27
+ end
28
+
29
+ def default_db_path
30
+ File.join(default_source_dir, '.index.sqlite')
31
+ end
32
+ end
33
+
34
+ attr_reader :db_path, :source_dir
35
+
36
+ def initialize(db_path: nil, source_dir: nil)
37
+ @source_dir = source_dir || self.class.default_source_dir
38
+ @db_path = db_path || File.join(@source_dir, '.index.sqlite')
39
+ end
40
+
41
+ def db
42
+ @db ||= SQLite3::Database.new(@db_path).tap { |d| d.results_as_hash = true }
43
+ end
44
+
45
+ def rebuild!
46
+ FileUtils.mkdir_p(@source_dir)
47
+ FileUtils.rm_f(@db_path)
48
+ @db = nil
49
+ create_schema!
50
+ sources = Dir["#{@source_dir}/*.esm.json"]
51
+ raise Esp.t('errors.reference_index.no_sources', dir: @source_dir) if sources.empty?
52
+
53
+ sources.sort.each { |path| index_esm(path) }
54
+ db.execute('ANALYZE')
55
+ count
56
+ end
57
+
58
+ def count
59
+ db.execute('SELECT COUNT(*) AS n FROM records').first['n']
60
+ end
61
+
62
+ # Search the index. By default `query` matches as a case-insensitive
63
+ # substring against BOTH id and name (so "Vivec" surfaces the god,
64
+ # the city's cells, the region, books, dialogue, …), with exact-id
65
+ # hits sorted to the top. Pass exact: true for a precise id lookup.
66
+ # `like` is an explicit SQL LIKE pattern on id; `type` filters by
67
+ # record type. All filters AND together.
68
+ def find(query: nil, type: nil, like: nil, exact: false, limit: 100)
69
+ clauses = []
70
+ params = []
71
+ apply_query(clauses, params, query, exact)
72
+ apply_filter(clauses, params, 'id LIKE ?', like)
73
+ apply_filter(clauses, params, 'type = ?', type)
74
+
75
+ sql = +'SELECT source_esm, record_index, type, id, name FROM records'
76
+ sql << " WHERE #{clauses.join(' AND ')}" unless clauses.empty?
77
+ sql << order_clause(query, exact, params)
78
+ sql << " LIMIT #{limit.to_i}"
79
+ db.execute(sql, params)
80
+ end
81
+
82
+ # Count of all rows matching the same filters (ignores limit), so
83
+ # callers can say "showing N of M".
84
+ def count_matching(query: nil, type: nil, like: nil, exact: false)
85
+ clauses = []
86
+ params = []
87
+ apply_query(clauses, params, query, exact)
88
+ apply_filter(clauses, params, 'id LIKE ?', like)
89
+ apply_filter(clauses, params, 'type = ?', type)
90
+
91
+ sql = +'SELECT COUNT(*) AS n FROM records'
92
+ sql << " WHERE #{clauses.join(' AND ')}" unless clauses.empty?
93
+ db.execute(sql, params).first['n']
94
+ end
95
+
96
+ # Pull the full JSON record for a hit by re-reading the source ESM.
97
+ def fetch_record(source_esm, record_index)
98
+ JSON.parse(File.read(File.join(@source_dir, "#{source_esm}.json")))[record_index]
99
+ end
100
+
101
+ # Map a batch of ids → record type via one SQL query against the
102
+ # case-insensitive id index. Keys come back lowercased; the caller
103
+ # downcases its own lookups to match. Unknown ids are absent from the
104
+ # returned hash. Backs the cell-view marker colouring (step 21 slice 3).
105
+ def types_for(ids)
106
+ lowered = Array(ids).map { |id| id.to_s.downcase }.uniq
107
+ return {} if lowered.empty?
108
+
109
+ placeholders = (['?'] * lowered.size).join(', ')
110
+ rows = db.execute(
111
+ "SELECT LOWER(id) AS id, type FROM records WHERE LOWER(id) IN (#{placeholders})",
112
+ lowered
113
+ )
114
+ # If duplicates exist across masters they're (in practice) the same
115
+ # type; last-wins is fine.
116
+ rows.to_h { |r| [r['id'], r['type']] }
117
+ end
118
+
119
+ private
120
+
121
+ def apply_query(clauses, params, query, exact)
122
+ return if query.nil?
123
+
124
+ if exact
125
+ clauses << 'LOWER(id) = LOWER(?)'
126
+ params << query
127
+ else
128
+ term = "%#{query.downcase}%"
129
+ clauses << '(LOWER(id) LIKE ? OR LOWER(name) LIKE ?)'
130
+ params.push(term, term)
131
+ end
132
+ end
133
+
134
+ def apply_filter(clauses, params, clause, value)
135
+ return if value.nil?
136
+
137
+ clauses << clause
138
+ params << value
139
+ end
140
+
141
+ # Exact-id hits float to the top of a substring search; otherwise
142
+ # plain alphabetical. Mutates params to bind the ORDER BY placeholder.
143
+ def order_clause(query, exact, params)
144
+ if query && !exact
145
+ params << query
146
+ ' ORDER BY (LOWER(id) = LOWER(?)) DESC, type, id'
147
+ else
148
+ ' ORDER BY source_esm, type, id'
149
+ end
150
+ end
151
+
152
+ def create_schema!
153
+ db.execute_batch(<<~SQL)
154
+ CREATE TABLE records (
155
+ source_esm TEXT NOT NULL,
156
+ record_index INTEGER NOT NULL,
157
+ type TEXT NOT NULL,
158
+ id TEXT,
159
+ name TEXT,
160
+ PRIMARY KEY (source_esm, record_index)
161
+ );
162
+ CREATE INDEX idx_records_type ON records(type);
163
+ CREATE INDEX idx_records_id_ci ON records(LOWER(id));
164
+ CREATE INDEX idx_records_id_lk ON records(id);
165
+ SQL
166
+ end
167
+
168
+ def index_esm(path)
169
+ source_esm = File.basename(path, '.json')
170
+ data = JSON.parse(File.read(path))
171
+ db.transaction
172
+ data.each_with_index do |record, idx|
173
+ db.execute(
174
+ 'INSERT INTO records (source_esm, record_index, type, id, name) VALUES (?, ?, ?, ?, ?)',
175
+ [source_esm, idx, record['type'], record['id'], record['name']]
176
+ )
177
+ end
178
+ db.commit
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,197 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'open3'
4
+
5
+ module Esp
6
+ module Mw
7
+ # Scaffolds a fresh mod folder under `mods/<Mod>/`. One TES3 Header
8
+ # record (pre-filled with sensible defaults), one README, and that's
9
+ # it — no script / i18n / design subdirectories until the author
10
+ # actually wants them. Picks the source format from `--format`;
11
+ # subprocess formats get a `#!` line and exec bit.
12
+ module Scaffolder
13
+ DEFAULT_FORMAT = 'json'.freeze
14
+ SUPPORTED_FORMATS = %w[json rb py js mjs ts].freeze
15
+ MORROWIND_ESM_SIZE = 79_837_557 # vanilla Steam build; users may need to edit
16
+ MOD_NAME_RE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
17
+
18
+ Result = Struct.new(:mod, :format, :source, :readme, keyword_init: true)
19
+
20
+ class << self
21
+ def create(mod, format: DEFAULT_FORMAT, author: nil, description: nil,
22
+ root: Esp::ROOT, force: false)
23
+ validate!(mod, format)
24
+ mod_dir = File.join(root, 'mods', mod)
25
+ if File.exist?(mod_dir) && !force
26
+ raise ArgumentError, Esp.t('errors.scaffolder.already_exists', mod: mod)
27
+ end
28
+
29
+ FileUtils.mkdir_p(mod_dir)
30
+ author ||= detect_author
31
+ description ||= "Morrowind plugin: #{mod}"
32
+
33
+ source = write_source(mod_dir, mod, format, author, description)
34
+ readme = write_readme(mod_dir, mod, author, description)
35
+ Result.new(mod: mod, format: format, source: source, readme: readme)
36
+ end
37
+
38
+ private
39
+
40
+ def validate!(mod, format)
41
+ unless mod.match?(MOD_NAME_RE)
42
+ raise ArgumentError, Esp.t('errors.scaffolder.bad_name', mod: mod.inspect)
43
+ end
44
+ return if SUPPORTED_FORMATS.include?(format)
45
+
46
+ raise ArgumentError, Esp.t('errors.scaffolder.bad_format',
47
+ format: format.inspect, formats: SUPPORTED_FORMATS.join(', '))
48
+ end
49
+
50
+ def detect_author
51
+ out, status = Open3.capture2('git', 'config', 'user.name')
52
+ status.success? && !out.strip.empty? ? out.strip : 'mw author'
53
+ rescue Errno::ENOENT
54
+ 'mw author'
55
+ end
56
+
57
+ def shebang_format?(format)
58
+ %w[py js mjs].include?(format)
59
+ end
60
+
61
+ def write_source(mod_dir, mod, format, author, description)
62
+ path = File.join(mod_dir, "#{mod}.#{format}")
63
+ File.write(path, render_source(format, author, description))
64
+ File.chmod(0o755, path) if shebang_format?(format)
65
+ path
66
+ end
67
+
68
+ def write_readme(mod_dir, mod, author, description)
69
+ path = File.join(mod_dir, 'README.md')
70
+ File.write(path, render_readme(mod, author, description))
71
+ path
72
+ end
73
+
74
+ def render_source(format, author, description)
75
+ case format
76
+ when 'json' then json_source(author, description)
77
+ when 'rb' then rb_source(author, description)
78
+ when 'py' then py_source(author, description)
79
+ when 'js', 'mjs' then js_source(author, description)
80
+ when 'ts' then ts_source(author, description)
81
+ end
82
+ end
83
+
84
+ def json_source(author, description)
85
+ "#{JSON.pretty_generate([header_record(author, description)])}\n"
86
+ end
87
+
88
+ def rb_source(author, description)
89
+ <<~RUBY
90
+ [
91
+ {
92
+ type: 'Header',
93
+ flags: '',
94
+ file_type: 'Esp',
95
+ version: 1.3,
96
+ author: #{author.inspect},
97
+ description: #{description.inspect},
98
+ num_objects: 0,
99
+ masters: [['Morrowind.esm', #{MORROWIND_ESM_SIZE}]]
100
+ }
101
+ ]
102
+ RUBY
103
+ end
104
+
105
+ def py_source(author, description)
106
+ <<~PY
107
+ #!/usr/bin/env python3
108
+ import json
109
+
110
+ records = [
111
+ {
112
+ "type": "Header",
113
+ "flags": "",
114
+ "file_type": "Esp",
115
+ "version": 1.3,
116
+ "author": #{author.to_json},
117
+ "description": #{description.to_json},
118
+ "num_objects": 0,
119
+ "masters": [["Morrowind.esm", #{MORROWIND_ESM_SIZE}]],
120
+ },
121
+ ]
122
+
123
+ print(json.dumps(records))
124
+ PY
125
+ end
126
+
127
+ def js_source(author, description)
128
+ <<~JS
129
+ #!/usr/bin/env node
130
+ const records = [
131
+ {
132
+ type: 'Header',
133
+ flags: '',
134
+ file_type: 'Esp',
135
+ version: 1.3,
136
+ author: #{author.to_json},
137
+ description: #{description.to_json},
138
+ num_objects: 0,
139
+ masters: [['Morrowind.esm', #{MORROWIND_ESM_SIZE}]]
140
+ }
141
+ ];
142
+ console.log(JSON.stringify(records));
143
+ JS
144
+ end
145
+
146
+ def ts_source(author, description)
147
+ <<~TS
148
+ // Run via the mw loader (deno run --quiet).
149
+ const records = [
150
+ {
151
+ type: 'Header',
152
+ flags: '',
153
+ file_type: 'Esp',
154
+ version: 1.3,
155
+ author: #{author.to_json},
156
+ description: #{description.to_json},
157
+ num_objects: 0,
158
+ masters: [['Morrowind.esm', #{MORROWIND_ESM_SIZE}]],
159
+ },
160
+ ];
161
+ console.log(JSON.stringify(records));
162
+ TS
163
+ end
164
+
165
+ def render_readme(mod, author, description)
166
+ <<~MD
167
+ # #{mod}
168
+
169
+ **Author:** #{author}
170
+
171
+ #{description}
172
+
173
+ ## Build
174
+
175
+ ```sh
176
+ bin/esp build #{mod}
177
+ bin/esp install #{mod}
178
+ ```
179
+ MD
180
+ end
181
+
182
+ def header_record(author, description)
183
+ {
184
+ 'type' => 'Header',
185
+ 'flags' => '',
186
+ 'file_type' => 'Esp',
187
+ 'version' => 1.3,
188
+ 'author' => author,
189
+ 'description' => description,
190
+ 'num_objects' => 0,
191
+ 'masters' => [['Morrowind.esm', MORROWIND_ESM_SIZE]]
192
+ }
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,87 @@
1
+ require 'base64'
2
+ require 'zstd-ruby'
3
+
4
+ module Esp
5
+ module Mw
6
+ # Synthesizes the ZSTD-framed blobs that tes3conv expects for a Script
7
+ # record's SCVR (variable list) and SCDT (bytecode) subrecords.
8
+ #
9
+ # Vanilla blobs use ZSTD frames whose blocks may be raw (small/empty
10
+ # scripts) or compressed-with-LZ77 matches (large scripts). tes3conv
11
+ # accepts either, so we always emit raw blocks — simpler, smaller code,
12
+ # and only marginally larger output. Decoding always goes through
13
+ # libzstd via zstd-ruby so we can extract names from any vanilla blob.
14
+ #
15
+ # Raw frame layout:
16
+ # 4 bytes ZSTD magic (28 b5 2f fd)
17
+ # 1 byte Frame Header Descriptor (0x00)
18
+ # 1 byte Window Descriptor (0x58)
19
+ # 3 bytes Block header ((size << 3) | type<<1 | last_block)
20
+ # N bytes Raw payload
21
+ #
22
+ # Payload layout (both SCVR and SCDT placeholder):
23
+ # 4 bytes LE uint32 — byte length of names section (0 if none)
24
+ # N bytes Null-terminated variable names, concatenated
25
+ #
26
+ # Empty vars and the bytecode placeholder both use a 4-byte zero payload
27
+ # (length prefix = 0). A truly zero-byte payload is rejected by tes3conv
28
+ # for SCDT and isn't what vanilla emits anywhere.
29
+ module ScriptBlob
30
+ ZSTD_MAGIC = "\x28\xb5\x2f\xfd".b.freeze
31
+ FHD = 0x00
32
+ WINDOW_DESCRIPTOR = 0x58
33
+
34
+ class << self
35
+ # Returns [base64_blob, variables_length] where variables_length is the
36
+ # byte count of the names section (matching the SCHD header field).
37
+ def variables(names)
38
+ if names.empty?
39
+ [wrap(("\x00" * 4).b), 0]
40
+ else
41
+ names_bytes = names.map { |n| "#{n.b}\x00".b }.join
42
+ payload = [names_bytes.bytesize].pack('V') + names_bytes
43
+ [wrap(payload), names_bytes.bytesize]
44
+ end
45
+ end
46
+
47
+ # SCDT placeholder. OpenMW recompiles bytecode from `text` on load,
48
+ # so we never emit real bytecode.
49
+ def empty_bytecode
50
+ wrap(("\x00" * 4).b)
51
+ end
52
+
53
+ # Decompress any vanilla SCVR blob and split out the variable names.
54
+ def decode_var_names(b64)
55
+ payload = decompress(b64)
56
+ return [] if payload.bytesize <= 4
57
+
58
+ names_section = payload.byteslice(4..)
59
+ names = names_section.split("\x00", -1)
60
+ names.pop while names.last == '' || names.last.nil?
61
+ names.map { |n| n.dup.force_encoding(Encoding::ASCII) }
62
+ end
63
+
64
+ # Decompress a blob and return its raw payload (length prefix + names).
65
+ # Useful for tests that compare semantic content across raw and
66
+ # compressed framings.
67
+ def decompress(b64)
68
+ Zstd.decompress(Base64.decode64(b64))
69
+ end
70
+
71
+ private
72
+
73
+ def wrap(payload)
74
+ raise ArgumentError, 'payload too large for 3-byte block header' if payload.bytesize >= (1 << 21)
75
+
76
+ raw = ZSTD_MAGIC + [FHD, WINDOW_DESCRIPTOR].pack('C*') + block_header(payload.bytesize) + payload
77
+ Base64.strict_encode64(raw)
78
+ end
79
+
80
+ def block_header(payload_len)
81
+ value = (payload_len << 3) | 1 # last_block=1, type=raw(0)
82
+ [value].pack('V').byteslice(0, 3)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,85 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+
4
+ module Esp
5
+ module Mw
6
+ # Inverse of preflight's text_source resolution. Given a JSON-format mod
7
+ # source whose Script records carry inline `text`, hoist each script
8
+ # body out to `scripts/<id>.mwscript` and replace the inline fields
9
+ # (`text`, `bytecode`, `variables`, `header`) with a single
10
+ # `text_source` pointer. Preflight regenerates everything on build, so
11
+ # the resulting source round-trips losslessly through `esp build`.
12
+ #
13
+ # Skips records that already have `text_source` set — re-running is a
14
+ # no-op. .rb sources are out of scope; their author owns the layout.
15
+ module ScriptExtractor
16
+ SAFE_ID = /\A[A-Za-z0-9_-]+\z/
17
+ Result = Struct.new(:extracted, :skipped, keyword_init: true)
18
+
19
+ class << self
20
+ def extract!(mod, root: Esp::ROOT)
21
+ source = Esp::Mw::Loader.resolve(mod, root: root)
22
+ unless source.end_with?('.json')
23
+ raise ArgumentError, Esp.t('errors.script_extractor.json_only', file: File.basename(source))
24
+ end
25
+
26
+ records = JSON.parse(File.read(source))
27
+ source_dir = File.dirname(source)
28
+ result = Result.new(extracted: [], skipped: [])
29
+
30
+ records.each do |record|
31
+ next unless record['type'] == 'Script'
32
+
33
+ process_record!(record, source_dir, result)
34
+ end
35
+
36
+ File.write(source, "#{JSON.pretty_generate(records)}\n")
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ def process_record!(record, source_dir, result)
43
+ sid = require_id!(record)
44
+ return result.skipped << sid if record['text_source']
45
+
46
+ require_safe_id!(sid)
47
+ text = require_text!(record, sid)
48
+
49
+ write_script_file(source_dir, sid, text)
50
+ record['text_source'] = "scripts/#{sid}.mwscript"
51
+ %w[text bytecode variables header].each { |k| record.delete(k) }
52
+ result.extracted << sid
53
+ end
54
+
55
+ def require_id!(record)
56
+ sid = record['id']
57
+ raise ArgumentError, Esp.t('errors.script_extractor.missing_id') if sid.nil? || sid.empty?
58
+
59
+ sid
60
+ end
61
+
62
+ def require_safe_id!(sid)
63
+ return if sid.match?(SAFE_ID)
64
+
65
+ raise ArgumentError, Esp.t('errors.script_extractor.unsafe_id', id: sid.inspect)
66
+ end
67
+
68
+ def require_text!(record, sid)
69
+ text = record['text']
70
+ if text.nil? || text.empty?
71
+ raise ArgumentError, Esp.t('errors.script_extractor.no_text', id: sid.inspect)
72
+ end
73
+
74
+ text
75
+ end
76
+
77
+ def write_script_file(source_dir, sid, text)
78
+ scripts_dir = File.join(source_dir, 'scripts')
79
+ FileUtils.mkdir_p(scripts_dir)
80
+ File.write(File.join(scripts_dir, "#{sid}.mwscript"), text)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,38 @@
1
+ require 'open3'
2
+
3
+ module Esp
4
+ module Mw
5
+ module Tes3conv
6
+ BIN = ENV.fetch('TES3CONV', 'tes3conv')
7
+
8
+ # The binary isn't on PATH at all.
9
+ class NotFound < StandardError; end
10
+
11
+ # tes3conv ran but rejected the conversion (bad input, missing field, …).
12
+ # Carries tes3conv's own stderr so the message is actionable.
13
+ class ConvertFailed < StandardError; end
14
+
15
+ class << self
16
+ def convert(input, output)
17
+ _out, err, status = Open3.capture3(BIN, '-o', input.to_s, output.to_s)
18
+ return if status.success?
19
+
20
+ raise ConvertFailed, failure_message(err)
21
+ rescue Errno::ENOENT
22
+ raise NotFound, Esp.t('errors.tes3conv.not_found', bin: BIN)
23
+ end
24
+
25
+ private
26
+
27
+ def failure_message(stderr)
28
+ msg = stderr.strip
29
+ msg = Esp.t('errors.tes3conv.failed') if msg.empty?
30
+ # The single most common authoring gotcha — every TES3 record needs
31
+ # the field even when empty. Point at the fix rather than the symptom.
32
+ msg += "\n#{Esp.t('errors.tes3conv.flags_hint')}" if msg.include?('missing field `flags`')
33
+ msg
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end