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,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
|