Module: Esp::Mw::Preflight

Defined in:
lib/esp/mw/preflight.rb

Overview

Resolves text_source into inline text and regenerates Script-record subrecords (SCHD header + SCVR vars + SCDT bytecode placeholder) into the form tes3conv expects. Mutates records in place.

Validation rules:

  • Source must be pure ASCII (MWScript’s compiler is silent on UTF-8).

  • Variable identifiers must be <= 20 chars (MWScript truncates ~20-23).

  • Warn (don’t fail) when a script’s ‘Begin <name>` doesn’t match its record id; vanilla often disagrees so this is intentionally soft.

Defined Under Namespace

Classes: ValidationError

Constant Summary collapse

VAR_DECL_RE =
/^\s*(short|long|float)\s+([A-Za-z_]\w*)\b/i
BEGIN_RE =
/^\s*begin\s+([A-Za-z_]\w*)\b/i
NAME_LIMIT =
20

Class Method Summary collapse

Class Method Details

.apply_script_data!(record, text, shorts, longs, floats) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/esp/mw/preflight.rb', line 90

def self.apply_script_data!(record, text, shorts, longs, floats)
  vars_blob, vars_length = Esp::Mw::ScriptBlob.variables(shorts + longs + floats)
  record['header'] = {
    'num_shorts' => shorts.size,
    'num_longs' => longs.size,
    'num_floats' => floats.size,
    'bytecode_length' => 0,
    'variables_length' => vars_length
  }
  record['variables'] = vars_blob
  record['bytecode'] = Esp::Mw::ScriptBlob.empty_bytecode
  record['text'] = text
  record.delete('text_source')
end

.collect_plugin_ids(records) ⇒ Object

Records this plugin defines (anything with a top-level ‘id`). Lowercased so the lookup is case-insensitive, matching how the engine and linter treat TES3 ids.



62
63
64
# File 'lib/esp/mw/preflight.rb', line 62

def self.collect_plugin_ids(records)
  records.filter_map { |r| r['id']&.to_s&.downcase }.to_set
end

.describe_cell(record) ⇒ Object



66
67
68
69
70
71
72
73
# File 'lib/esp/mw/preflight.rb', line 66

def self.describe_cell(record)
  name = record['name'].to_s
  grid = record.dig('data', 'grid')
  return "cell #{name.inspect}" unless name.empty?
  return "exterior cell #{grid.inspect}" if grid

  'cell <unknown>'
end

.parse_variables(text, label) ⇒ Object



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/esp/mw/preflight.rb', line 131

def self.parse_variables(text, label)
  shorts = []
  longs = []
  floats = []
  begin_name = nil
  text.each_line do |line|
    stripped = line.lstrip
    next if stripped.start_with?(';')

    if (m = BEGIN_RE.match(stripped))
      begin_name = m[1]
      next
    end
    next unless (m = VAR_DECL_RE.match(line))

    kind = m[1].downcase
    name = m[2]
    if name.length > NAME_LIMIT
      raise ValidationError, Esp.t('errors.preflight.var_too_long', label: label,
                                                                    name: name.inspect,
                                                                    length: name.length,
                                                                    limit: NAME_LIMIT)
    end
    bucket = { 'short' => shorts, 'long' => longs, 'float' => floats }[kind]
    bucket << name
  end
  [shorts, longs, floats, begin_name]
end

.process!(records, source_dir:) ⇒ Object

Walks records and processes Script + Cell entries. Returns a list of log lines (info + warnings) for the caller to print.



23
24
25
26
27
28
29
30
31
32
33
# File 'lib/esp/mw/preflight.rb', line 23

def self.process!(records, source_dir:)
  logs = []
  plugin_ids = collect_plugin_ids(records)
  records.each do |record|
    case record['type']
    when 'Script' then logs.concat(process_script!(record, source_dir))
    when 'Cell'   then logs.concat(process_cell!(record, plugin_ids))
    end
  end
  logs
end

.process_cell!(record, plugin_ids) ⇒ Object

Defaults missing ‘mast_index` on cell references. tes3conv requires the field on every reference; the convention is `0` for plugin-local records and N>0 for master records (1 = first listed master, etc.). We auto-fill 0 when the referenced id is defined in this plugin —the common case for new content — and emit a clear error otherwise so the user doesn’t see tes3conv’s cryptic “missing field” message.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/esp/mw/preflight.rb', line 41

def self.process_cell!(record, plugin_ids)
  cell_label = describe_cell(record)
  logs = []
  Array(record['references']).each_with_index do |ref, i|
    next if ref.key?('mast_index')

    ref_id = ref['id'].to_s
    if plugin_ids.include?(ref_id.downcase)
      ref['mast_index'] = 0
      logs << "#{cell_label}: ref[#{i}] #{ref_id.inspect} — defaulted mast_index to 0 (plugin-local)"
    else
      raise ValidationError, Esp.t('errors.preflight.master_ref_needs_mast_index',
                                   cell: cell_label, id: ref_id)
    end
  end
  logs
end

.process_script!(record, source_dir) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/esp/mw/preflight.rb', line 75

def self.process_script!(record, source_dir)
  sid = record['id'] || '<unknown>'
  text, label = resolve_text(record, source_dir)
  return ["#{sid}: empty text, skipping"] if text.empty?

  validate_ascii!(text, label)
  shorts, longs, floats, begin_name = parse_variables(text, label)
  logs = []
  if begin_name && begin_name.downcase != sid.downcase
    logs << "#{sid}: WARNING — Begin name #{begin_name.inspect} does not match record id"
  end
  apply_script_data!(record, text, shorts, longs, floats)
  logs
end

.resolve_text(record, source_dir) ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/esp/mw/preflight.rb', line 105

def self.resolve_text(record, source_dir)
  ts = record['text_source']
  return [record['text'].to_s, "<inline text for #{record['id'] || '?'}>"] unless ts

  path = File.expand_path(ts, source_dir)
  unless File.file?(path)
    raise ValidationError,
          Esp.t('errors.preflight.text_source_missing', path: path)
  end

  [File.read(path), path]
end

.validate_ascii!(text, label) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/esp/mw/preflight.rb', line 118

def self.validate_ascii!(text, label)
  text.each_char.with_index do |c, i|
    next if c.ord < 128

    prefix = text[0, i]
    line = prefix.count("\n") + 1
    col  = i - (prefix.rindex("\n") || -1)
    hex  = format('%04X', c.ord)
    raise ValidationError, Esp.t('errors.preflight.non_ascii',
                                 label: label, char: c.inspect, hex: hex, line: line, col: col)
  end
end