sci2-ts 0.8.2 → 0.10.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.
@@ -7,7 +7,8 @@ import { parseYarn, type YarnChoice, type YarnCommand, type YarnExpr, type YarnI
7
7
  * (.sca) and message text (.msg), the formats tools/mod-build.ts already builds from.
8
8
  *
9
9
  * room: 701
10
- * hero: { at: [36, 152], enterTo: [110, 160], scale: { front: 100, back: 70, frontY: 186, backY: 122 } }
10
+ * hero: { at: [36, 152], enterTo: [110, 160] }
11
+ * perspective: { horizon: 72, fullSize: 176 }
11
12
  * walkable: [[28, 152], [60, 135], ...]
12
13
  * features:
13
14
  * pods: { rect: [236, 14, 310, 94] }
@@ -31,9 +32,17 @@ export interface RoomSpec {
31
32
  at?: Point;
32
33
  /** Walk here on arrival (hands off until there). */
33
34
  enterTo?: Point;
34
- /** Perspective: `front`% at y `frontY`, `back`% at y `backY`. */
35
+ /** Perspective for the hero alone: `front`% at y `frontY`, `back`% at y `backY` (perspective: is simpler). */
35
36
  scale?: { front: number; back: number; frontY: number; backY: number };
36
37
  };
38
+ /**
39
+ * The picture's camera, for sizing people by where they stand: the horizon (the y the
40
+ * floor's lines meet at), and the y where a figure's feet are when it's drawn at full
41
+ * size. A figure's height is in proportion to how far below the horizon it stands, so it
42
+ * shrinks towards the back and grows past fullSize at the front. The hero and props that
43
+ * move are sized as they go; a still prop only with scale: true.
44
+ */
45
+ perspective?: { horizon: number; fullSize: number };
37
46
  /** The floor: a polygon the hero stays inside. */
38
47
  walkable?: Point[];
39
48
  /** Polygons the hero walks around. */
@@ -87,6 +96,11 @@ interface PropSpec {
87
96
  sightAngle?: number;
88
97
  /** A Script class of the game's: an instance of it runs on the prop (ambient behaviour). */
89
98
  script?: string;
99
+ /**
100
+ * Sized by the room's perspective: by default a prop that moves is and a still one
101
+ * (drawn to fit its place, like someone in a chair) isn't.
102
+ */
103
+ scale?: boolean;
90
104
  }
91
105
 
92
106
  export interface CompiledRoom {
@@ -99,6 +113,30 @@ export interface CompiledRoom {
99
113
  roomTalkers: boolean;
100
114
  /** Yarn variables ($name) used: each is a flag, numbered by `options.flag`. */
101
115
  flags: string[];
116
+ /** The spoken lines (not menu labels), with their message keys today. */
117
+ lines: SpokenLine[];
118
+ }
119
+
120
+ /**
121
+ * A line someone says. Its message key (noun, verb, cond, seq) is positional and changes
122
+ * when lines or things are added before it; its id (#line:) doesn't, so recordings use that.
123
+ */
124
+ export interface SpokenLine {
125
+ id?: string;
126
+ noun: number;
127
+ verb: number;
128
+ cond: number;
129
+ seq: number;
130
+ talker: number;
131
+ /** As written in the Yarn (Narrator when it has none). */
132
+ speaker: string;
133
+ text: string;
134
+ /** The Yarn node it's in (and for a topic's answer, the topic). */
135
+ node: string;
136
+ /** What's said just before it in the same node, if anything: context for whoever records it. */
137
+ before?: { speaker: string; text: string };
138
+ /** file:line in the Yarn. */
139
+ where: string;
102
140
  }
103
141
 
104
142
  export interface CompileOptions {
@@ -157,7 +195,7 @@ class SpecChecker {
157
195
  check(): void {
158
196
  const s = this.spec as unknown as Record<string, unknown>;
159
197
  if (!s || typeof s !== "object") this.fail([], "expected a mapping (room: ..., features: ...)");
160
- this.known(s, [], ["room", "picture", "music", "hero", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
198
+ this.known(s, [], ["room", "picture", "music", "hero", "perspective", "walkable", "obstacles", "features", "exits", "props", "characters", "properties"]);
161
199
  this.int(s.room, ["room"], 0, 65535);
162
200
  if (s.picture !== undefined) this.int(s.picture, ["picture"], 0, 65535);
163
201
  if (s.music !== undefined) this.int(s.music, ["music"], 0, 65535);
@@ -172,6 +210,13 @@ class SpecChecker {
172
210
  for (const k of ["front", "back", "frontY", "backY"]) this.int(sc[k], ["hero", "scale", k], 0, 1000);
173
211
  }
174
212
  }
213
+ if (s.perspective !== undefined) {
214
+ const pv = this.map(s.perspective, ["perspective"]);
215
+ this.known(pv, ["perspective"], ["horizon", "fullSize"]);
216
+ this.int(pv.horizon, ["perspective", "horizon"], -1000, 199);
217
+ this.int(pv.fullSize, ["perspective", "fullSize"], 0, 1000);
218
+ if ((pv.fullSize as number) <= (pv.horizon as number)) this.fail(["perspective", "fullSize"], "must be below the horizon (a bigger y)");
219
+ }
175
220
  if (s.walkable !== undefined) this.polygon(s.walkable, ["walkable"]);
176
221
  if (s.obstacles !== undefined) {
177
222
  if (!Array.isArray(s.obstacles)) this.fail(["obstacles"], "expected a list of polygons");
@@ -188,7 +233,9 @@ class SpecChecker {
188
233
  names.add(name);
189
234
  const t = this.map(value, path);
190
235
  if (group === "props") {
191
- this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script"]);
236
+ this.known(t, path, ["view", "at", "loop", "cel", "cycle", "sightAngle", "moves", "script", "scale"]);
237
+ if (t.scale !== undefined && typeof t.scale !== "boolean") this.fail([...path, "scale"], "expected true or false");
238
+ if (t.scale === true && s.perspective === undefined) this.fail([...path, "scale"], "the room has no perspective: to size it by");
192
239
  if (t.script !== undefined && (typeof t.script !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(t.script))) this.fail([...path, "script"], "expected a class name, e.g. Scurry");
193
240
  if (t.moves !== undefined && typeof t.moves !== "boolean") this.fail([...path, "moves"], "expected true or false");
194
241
  this.int(t.view, [...path, "view"], 0, 65535);
@@ -265,6 +312,7 @@ type Val = number | { obj: string } | { cls: string } | "self" | { code: string[
265
312
  /** Who receives a send. */
266
313
  type To = { global: number } | { obj: string } | { cls: string } | "self" | { superOf: string };
267
314
 
315
+ const SCREEN_BOTTOM = 200;
268
316
  const num = (n: number) => (n < 0 ? `-$${(-n).toString(16)}` : String(n));
269
317
 
270
318
  function pushVal(v: Val): string[] {
@@ -300,6 +348,7 @@ interface Message {
300
348
  cond: number;
301
349
  seq: number;
302
350
  talker: number;
351
+ speaker: string;
303
352
  text: string;
304
353
  label: string;
305
354
  }
@@ -347,6 +396,7 @@ class RoomCompiler {
347
396
  readonly name: string;
348
397
  readonly nouns = new Map<string, number>();
349
398
  readonly messages: Message[] = [];
399
+ readonly lines: SpokenLine[] = [];
350
400
  readonly instances: string[] = [];
351
401
  readonly code: string[] = [];
352
402
  readonly strings: string[] = [];
@@ -425,7 +475,7 @@ class RoomCompiler {
425
475
 
426
476
  return {
427
477
  number: spec.room, sca: this.assembly(), msg: this.messageText(), roomTalkers: characters.length > 0,
428
- flags: [...this.flagsUsed].sort(),
478
+ flags: [...this.flagsUsed].sort(), lines: this.lines,
429
479
  };
430
480
  }
431
481
 
@@ -492,7 +542,7 @@ class RoomCompiler {
492
542
  if (!c.body.length) fail(`choice "${c.text}" needs at least one line under it`, c.line);
493
543
  const topic = k + 1;
494
544
  const label = `${node.title}: ${c.text}`;
495
- this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label);
545
+ this.message(conv.rootNoun, tv, topic, 1, { kind: "line", speaker: "Hero", text: c.text, line: c.line }, label, false);
496
546
  if (c.when) conv.conditional.push({ topic, when: c.when, where: `${this.yarnFile}:${c.line}` });
497
547
  if (c.body.every((x) => x.kind === "line")) {
498
548
  c.body.forEach((l, j) => this.message(conv.sayNoun, tv, topic, j + 1, l as YarnLine, label));
@@ -794,9 +844,19 @@ class RoomCompiler {
794
844
 
795
845
  // --- Objects ---
796
846
 
797
- private message(noun: number, verb: number, cond: number, seq: number, l: YarnLine, label: string): void {
847
+ /** A message; `spoken` is false for a topic menu's labels, which nobody says. */
848
+ private message(noun: number, verb: number, cond: number, seq: number, l: YarnLine, label: string, spoken = true): void {
798
849
  const where = `${this.yarnFile}:${l.line}`;
799
- this.messages.push({ noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), text: plainText(l.text, where), label });
850
+ const prev = this.messages.at(-1);
851
+ const m: Message = { noun, verb, cond, seq, talker: this.talkerOf(l.speaker, where), speaker: l.speaker ?? "Narrator", text: plainText(l.text, where), label };
852
+ this.messages.push(m);
853
+ if (!spoken) return;
854
+ const twice = l.id !== undefined && this.lines.find((x) => x.id === l.id);
855
+ if (twice) throw new ContentError(`${where}: #line:${l.id} is already the id of ${twice.where}`);
856
+ this.lines.push({
857
+ ...(l.id === undefined ? {} : { id: l.id }), noun, verb, cond, seq, talker: m.talker, speaker: m.speaker, text: m.text, node: label,
858
+ ...(prev?.label === label ? { before: { speaker: prev.speaker, text: prev.text } } : {}), where,
859
+ });
800
860
  }
801
861
 
802
862
  private talkerOf(speaker: string | undefined, where: string): number {
@@ -836,13 +896,18 @@ class RoomCompiler {
836
896
  const egoCalls: [string, Val[]][] = [["init", []]];
837
897
  if (hero?.at) egoCalls.push(["x", [hero.at[0]]], ["y", [hero.at[1]]]);
838
898
  egoCalls.push(["normalize", []]);
899
+ // Each room sizes the hero its own way, or not at all.
900
+ const perspective = this.perspective();
839
901
  if (hero?.scale) egoCalls.push(["setScaler", [{ cls: "Scaler" }, hero.scale.front, hero.scale.back, hero.scale.frontY, hero.scale.backY]]);
902
+ else if (perspective) egoCalls.push(["setScaler", [{ cls: "Scaler" }, ...perspective]]);
903
+ else egoCalls.push(["setScaler", [0]]);
840
904
  c.push(...send({ global: target.globals.ego }, egoCalls));
841
905
  if (spec.music !== undefined) c.push(...this.playMusic(spec.music));
842
906
  for (const name of [...Object.keys(spec.features ?? {}), ...Object.keys(spec.exits ?? {})]) c.push(...send({ obj: name }, [["init", []]]));
843
907
  for (const [name, p] of Object.entries(spec.props ?? {})) {
844
908
  c.push(...send({ obj: name }, [
845
909
  ["init", []],
910
+ ...(perspective && p.moves && p.scale !== false ? [["setScaler", [{ cls: "Scaler" }, ...perspective]] as [string, Val[]]] : []),
846
911
  ...(p.cycle === "forward" ? [["setCycle", [{ cls: this.target.forwardCycle }]] as [string, Val[]]] : []),
847
912
  // A new instance of the game's Script class, running on the prop.
848
913
  ...(p.script ? [["setScript", [{ code: ["pushi #new", "push0", `class ${p.script}`, "send 4"] }]] as [string, Val[]]] : []),
@@ -974,11 +1039,39 @@ class RoomCompiler {
974
1039
  if (cases.length) this.doVerb(name, "Feature", cases);
975
1040
  }
976
1041
 
1042
+ /**
1043
+ * The room's perspective as Scaler arguments (front, back, frontY, backY): sizes at the
1044
+ * nearest and farthest places anyone stands (the floor, and where the hero and props that
1045
+ * move start). Size is a straight line in y, so the Scaler's in-between is exact; keeping
1046
+ * to that range keeps its 16-bit arithmetic from overflowing.
1047
+ */
1048
+ private perspective(): [number, number, number, number] | undefined {
1049
+ const pv = this.spec.perspective;
1050
+ if (!pv) return undefined;
1051
+ const ys = [
1052
+ ...(this.spec.walkable ?? []).map((p) => p[1]),
1053
+ ...Object.values(this.spec.props ?? {}).filter((p) => p.moves).map((p) => p.at[1]),
1054
+ ...[this.spec.hero?.at, this.spec.hero?.enterTo].flatMap((p) => (p ? [p[1]] : [])),
1055
+ ].filter((y) => y > pv.horizon);
1056
+ const backY = ys.length ? Math.min(...ys) : pv.horizon + 1, frontY = ys.length ? Math.max(...ys) : SCREEN_BOTTOM - 1;
1057
+ const size = (y: number) => Math.round(((y - pv.horizon) / (pv.fullSize - pv.horizon)) * 100);
1058
+ const [front, back] = [size(frontY), size(backY)];
1059
+ // Scaler works out (front - back) * (y - backY) on the way.
1060
+ if ((front - back) * (frontY - backY) > 32767) throw new ContentError(`room ${this.spec.room}: perspective: sizes from ${back}% to ${front}% over ${frontY - backY} lines are too much for the Scaler`);
1061
+ return [front, back, Math.max(frontY, backY + 1), backY];
1062
+ }
1063
+
977
1064
  private prop(name: string, p: PropSpec): void {
978
1065
  const cases = this.scriptedVerbs.get(name) ?? [];
1066
+ // A still prop is sized once, for where it is.
1067
+ const pv = this.spec.perspective;
1068
+ const fixed = pv && p.scale === true && !p.moves ? Math.round(((p.at[1] - pv.horizon) / (pv.fullSize - pv.horizon)) * 128) : undefined;
979
1069
  this.instance(
980
1070
  name, p.moves ? "Actor" : "Prop",
981
- { noun: this.nouns.get(name)!, modNum: -1, sightAngle: p.sightAngle ?? 180, x: p.at[0], y: p.at[1], view: p.view, loop: p.loop ?? 0, cel: p.cel ?? 0 },
1071
+ {
1072
+ noun: this.nouns.get(name)!, modNum: -1, sightAngle: p.sightAngle ?? 180, x: p.at[0], y: p.at[1], view: p.view, loop: p.loop ?? 0, cel: p.cel ?? 0,
1073
+ ...(fixed === undefined ? {} : { scaleSignal: 1, scaleX: fixed, scaleY: fixed }),
1074
+ },
982
1075
  cases.length ? ["doVerb"] : [],
983
1076
  );
984
1077
  if (cases.length) this.doVerb(name, p.moves ? "Actor" : "Prop", cases);
@@ -40,6 +40,8 @@ export interface YarnLine {
40
40
  kind: "line";
41
41
  speaker?: string;
42
42
  text: string;
43
+ /** Its #line: tag, which stays with it when lines around it change (recordings use it). */
44
+ id?: string;
43
45
  line: number;
44
46
  }
45
47
  export interface YarnChoice {
@@ -88,3 +90,14 @@ export declare class YarnError extends Error {
88
90
  export declare function parseYarn(source: string, file?: string): YarnNode[];
89
91
  /** `$a and not ($b or $c == false)`: Yarn's boolean operators, both spellings. */
90
92
  export declare function parseExpr(source: string, fail: (why: string) => never): YarnExpr;
93
+ /** Every line in some items, including those under choices and in <<if>> branches. */
94
+ export declare function yarnLines(items: YarnItem[]): YarnLine[];
95
+ /**
96
+ * Gives every line without a #line: tag one, `<prefix>-001` and on, skipping ids already
97
+ * `taken` (pass the game's, so they're unique across rooms). Lines that have an id keep it,
98
+ * and nothing else in the file changes.
99
+ */
100
+ export declare function tagLines(source: string, prefix: string, taken: Set<string>, file?: string): {
101
+ source: string;
102
+ added: string[];
103
+ };
@@ -8,7 +8,18 @@ export class YarnError extends Error {
8
8
  }
9
9
  }
10
10
  /** Trailing #tags (Yarn's line ids and metadata) don't belong to the text. */
11
- const stripTags = (s) => s.replace(/(\s+#[^\s#]+)+\s*$/, "");
11
+ const TAGS = /(\s+#[^\s#]+)+\s*$/;
12
+ const stripTags = (s) => s.replace(TAGS, "");
13
+ /** The id in a line's #line: tag, if it has one. */
14
+ function lineId(s, fail) {
15
+ const tags = TAGS.exec(s)?.[0].trim().split(/\s+/) ?? [];
16
+ const ids = tags.filter((t) => t.startsWith("#line:")).map((t) => t.slice(6));
17
+ if (ids.length > 1)
18
+ fail("a line can only have one #line: tag");
19
+ if (ids[0] !== undefined && !/^[A-Za-z0-9_-]+$/.test(ids[0]))
20
+ fail(`"#line:${ids[0]}": a line id is letters, digits, - and _`);
21
+ return ids[0];
22
+ }
12
23
  const indentOf = (s) => /^[ \t]*/.exec(s)[0].replace(/\t/g, " ").length;
13
24
  export function parseYarn(source, file = "<yarn>") {
14
25
  const nodes = [];
@@ -131,8 +142,10 @@ class BodyParser {
131
142
  if (text.startsWith("<<"))
132
143
  this.fail(line, `expected <<command ...>>, got "${text}"`);
133
144
  const clean = stripTags(text);
145
+ const id = lineId(text, (why) => this.fail(line, why));
134
146
  const m = /^([A-Za-z][\w ]*?)\s*:\s+(.+)$/.exec(clean);
135
- items.push(m ? { kind: "line", speaker: m[1], text: m[2], line } : { kind: "line", text: clean, line });
147
+ const said = m ? { speaker: m[1], text: m[2] } : { text: clean };
148
+ items.push({ kind: "line", ...said, ...(id === undefined ? {} : { id }), line });
136
149
  }
137
150
  return items;
138
151
  }
@@ -212,3 +225,40 @@ export function parseExpr(source, fail) {
212
225
  fail(`unexpected "${tokens[p]}" in "${source}"`);
213
226
  return e;
214
227
  }
228
+ /** Every line in some items, including those under choices and in <<if>> branches. */
229
+ export function yarnLines(items) {
230
+ return items.flatMap((x) => x.kind === "line" ? [x]
231
+ : x.kind === "choice" ? yarnLines(x.body)
232
+ : x.kind === "if" ? x.branches.flatMap((b) => yarnLines(b.body))
233
+ : []);
234
+ }
235
+ /**
236
+ * Gives every line without a #line: tag one, `<prefix>-001` and on, skipping ids already
237
+ * `taken` (pass the game's, so they're unique across rooms). Lines that have an id keep it,
238
+ * and nothing else in the file changes.
239
+ */
240
+ export function tagLines(source, prefix, taken, file = "<yarn>") {
241
+ const lines = parseYarn(source, file).flatMap((n) => yarnLines(n.body));
242
+ for (const l of lines)
243
+ if (l.id !== undefined)
244
+ taken.add(l.id);
245
+ const untagged = new Set(lines.filter((l) => l.id === undefined).map((l) => l.line));
246
+ const added = [];
247
+ let n = 0;
248
+ const next = () => {
249
+ let id;
250
+ do
251
+ id = `${prefix}-${String(++n).padStart(3, "0")}`;
252
+ while (taken.has(id));
253
+ taken.add(id);
254
+ added.push(id);
255
+ return id;
256
+ };
257
+ const out = source.split("\n").map((text, i) => {
258
+ if (!untagged.has(i + 1))
259
+ return text;
260
+ const cr = text.endsWith("\r") ? "\r" : "";
261
+ return `${text.slice(0, text.length - cr.length).trimEnd()} #line:${next()}${cr}`;
262
+ });
263
+ return { source: out.join("\n"), added };
264
+ }
@@ -43,6 +43,8 @@ export interface YarnLine {
43
43
  kind: "line";
44
44
  speaker?: string;
45
45
  text: string;
46
+ /** Its #line: tag, which stays with it when lines around it change (recordings use it). */
47
+ id?: string;
46
48
  line: number;
47
49
  }
48
50
 
@@ -83,7 +85,16 @@ export class YarnError extends Error {
83
85
  }
84
86
 
85
87
  /** Trailing #tags (Yarn's line ids and metadata) don't belong to the text. */
86
- const stripTags = (s: string) => s.replace(/(\s+#[^\s#]+)+\s*$/, "");
88
+ const TAGS = /(\s+#[^\s#]+)+\s*$/;
89
+ const stripTags = (s: string) => s.replace(TAGS, "");
90
+ /** The id in a line's #line: tag, if it has one. */
91
+ function lineId(s: string, fail: (why: string) => never): string | undefined {
92
+ const tags = TAGS.exec(s)?.[0].trim().split(/\s+/) ?? [];
93
+ const ids = tags.filter((t) => t.startsWith("#line:")).map((t) => t.slice(6));
94
+ if (ids.length > 1) fail("a line can only have one #line: tag");
95
+ if (ids[0] !== undefined && !/^[A-Za-z0-9_-]+$/.test(ids[0])) fail(`"#line:${ids[0]}": a line id is letters, digits, - and _`);
96
+ return ids[0];
97
+ }
87
98
  const indentOf = (s: string) => /^[ \t]*/.exec(s)![0].replace(/\t/g, " ").length;
88
99
 
89
100
  export function parseYarn(source: string, file = "<yarn>"): YarnNode[] {
@@ -189,8 +200,10 @@ class BodyParser {
189
200
  }
190
201
  if (text.startsWith("<<")) this.fail(line, `expected <<command ...>>, got "${text}"`);
191
202
  const clean = stripTags(text);
203
+ const id = lineId(text, (why) => this.fail(line, why));
192
204
  const m = /^([A-Za-z][\w ]*?)\s*:\s+(.+)$/.exec(clean);
193
- items.push(m ? { kind: "line", speaker: m[1]!, text: m[2]!, line } : { kind: "line", text: clean, line });
205
+ const said = m ? { speaker: m[1]!, text: m[2]! } : { text: clean };
206
+ items.push({ kind: "line", ...said, ...(id === undefined ? {} : { id }), line });
194
207
  }
195
208
  return items;
196
209
  }
@@ -259,3 +272,39 @@ export function parseExpr(source: string, fail: (why: string) => never): YarnExp
259
272
  if (p < tokens.length) fail(`unexpected "${tokens[p]}" in "${source}"`);
260
273
  return e;
261
274
  }
275
+
276
+ /** Every line in some items, including those under choices and in <<if>> branches. */
277
+ export function yarnLines(items: YarnItem[]): YarnLine[] {
278
+ return items.flatMap((x) =>
279
+ x.kind === "line" ? [x]
280
+ : x.kind === "choice" ? yarnLines(x.body)
281
+ : x.kind === "if" ? x.branches.flatMap((b) => yarnLines(b.body))
282
+ : []);
283
+ }
284
+
285
+ /**
286
+ * Gives every line without a #line: tag one, `<prefix>-001` and on, skipping ids already
287
+ * `taken` (pass the game's, so they're unique across rooms). Lines that have an id keep it,
288
+ * and nothing else in the file changes.
289
+ */
290
+ export function tagLines(source: string, prefix: string, taken: Set<string>, file = "<yarn>"): { source: string; added: string[] } {
291
+ const lines = parseYarn(source, file).flatMap((n) => yarnLines(n.body));
292
+ for (const l of lines) if (l.id !== undefined) taken.add(l.id);
293
+ const untagged = new Set(lines.filter((l) => l.id === undefined).map((l) => l.line));
294
+ const added: string[] = [];
295
+ let n = 0;
296
+ const next = () => {
297
+ let id: string;
298
+ do id = `${prefix}-${String(++n).padStart(3, "0")}`;
299
+ while (taken.has(id));
300
+ taken.add(id);
301
+ added.push(id);
302
+ return id;
303
+ };
304
+ const out = source.split("\n").map((text, i) => {
305
+ if (!untagged.has(i + 1)) return text;
306
+ const cr = text.endsWith("\r") ? "\r" : "";
307
+ return `${text.slice(0, text.length - cr.length).trimEnd()} #line:${next()}${cr}`;
308
+ });
309
+ return { source: out.join("\n"), added };
310
+ }
@@ -29,4 +29,6 @@ export interface SolHeader {
29
29
  export declare function parseSolHeader(data: Uint8Array): SolHeader | undefined;
30
30
  /** Samples a SOL clip of `dataBytes` bytes decodes to, per channel. */
31
31
  export declare function solSampleCount(flags: number, dataBytes: number): number;
32
+ /** The steps of 16-bit DPCM, by the low seven bits of a byte (bit 7 makes it a step down). */
33
+ export declare const DPCM16: number[];
32
34
  export declare function decodeSol(data: Uint8Array): SolAudio | undefined;
@@ -17,7 +17,8 @@ export function solSampleCount(flags, dataBytes) {
17
17
  const bytesPerSample = flags & SolFlag.Compressed ? (flags & SolFlag.Bits16 ? 1 : 0.5) : flags & SolFlag.Bits16 ? 2 : 1;
18
18
  return Math.floor(dataBytes / bytesPerSample / channels);
19
19
  }
20
- const DPCM16 = [
20
+ /** The steps of 16-bit DPCM, by the low seven bits of a byte (bit 7 makes it a step down). */
21
+ export const DPCM16 = [
21
22
  0x0000, 0x0008, 0x0010, 0x0020, 0x0030, 0x0040, 0x0050, 0x0060, 0x0070, 0x0080, 0x0090, 0x00a0, 0x00b0, 0x00c0,
22
23
  0x00d0, 0x00e0, 0x00f0, 0x0100, 0x0110, 0x0120, 0x0130, 0x0140, 0x0150, 0x0160, 0x0170, 0x0180, 0x0190, 0x01a0,
23
24
  0x01b0, 0x01c0, 0x01d0, 0x01e0, 0x01f0, 0x0200, 0x0208, 0x0210, 0x0218, 0x0220, 0x0228, 0x0230, 0x0238, 0x0240,
@@ -43,7 +43,8 @@ export function solSampleCount(flags: number, dataBytes: number): number {
43
43
  return Math.floor(dataBytes / bytesPerSample / channels);
44
44
  }
45
45
 
46
- const DPCM16 = [
46
+ /** The steps of 16-bit DPCM, by the low seven bits of a byte (bit 7 makes it a step down). */
47
+ export const DPCM16 = [
47
48
  0x0000, 0x0008, 0x0010, 0x0020, 0x0030, 0x0040, 0x0050, 0x0060, 0x0070, 0x0080, 0x0090, 0x00a0, 0x00b0, 0x00c0,
48
49
  0x00d0, 0x00e0, 0x00f0, 0x0100, 0x0110, 0x0120, 0x0130, 0x0140, 0x0150, 0x0160, 0x0170, 0x0180, 0x0190, 0x01a0,
49
50
  0x01b0, 0x01c0, 0x01d0, 0x01e0, 0x01f0, 0x0200, 0x0208, 0x0210, 0x0218, 0x0220, 0x0228, 0x0230, 0x0238, 0x0240,
@@ -20,8 +20,43 @@ export interface SoundSpec {
20
20
  }[];
21
21
  }
22
22
  export declare function writeSound(spec: SoundSpec): Uint8Array;
23
- /** A SOL clip: 16-bit samples at `rate`, uncompressed. */
24
- export declare function writeSol(samples: Int16Array, rate: number): Uint8Array;
23
+ /**
24
+ * A SOL clip of 16-bit samples at `rate`: uncompressed, or compressed with SOL's 16-bit DPCM
25
+ * (one byte a sample, half the size; what speech uses).
26
+ */
27
+ export declare function writeSol(samples: Int16Array, rate: number, compressed?: boolean): Uint8Array;
28
+ /**
29
+ * SOL's 16-bit DPCM: each byte steps from the last sample by DPCM16[byte & 0x7f], down if
30
+ * bit 7 is set. Each step is the one that lands nearest the sample, never past the 16-bit
31
+ * range (the decoder would wrap around).
32
+ */
33
+ export declare function encodeDpcm16(samples: Int16Array): Uint8Array;
34
+ export interface SpeechClip {
35
+ /** The message file (room) the line is in, and its key there. */
36
+ module: number;
37
+ noun: number;
38
+ verb: number;
39
+ cond: number;
40
+ seq: number;
41
+ sol: Uint8Array;
42
+ }
43
+ /**
44
+ * RESOURCE.AUD (SOL clips one after another, by module and key) and a map per module, in the
45
+ * layout AudioIndex reads: u32 offset of the module's first clip, then per clip u32
46
+ * big-endian noun<<24|verb<<16|cond<<8|seq and a u24 delta from the previous clip;
47
+ * 0xFFFFFFFF ends it.
48
+ *
49
+ * The index takes a clip's length from where the next one starts, so the last map also has
50
+ * an entry at the end of the file, under 0 0 0 0, which no message has (nouns and seqs
51
+ * start at 1); without it the last clip's length would be a guess.
52
+ */
53
+ export declare function writeSpeech(clips: readonly SpeechClip[]): {
54
+ aud: Uint8Array;
55
+ maps: {
56
+ module: number;
57
+ data: Uint8Array;
58
+ }[];
59
+ };
25
60
  /**
26
61
  * RESOURCE.SFX (the SOL clips, one after another) and its index, map 65535: per clip, u16
27
62
  * sound number and u24 offset from the previous clip; 0xFFFF ends it.
@@ -1,4 +1,4 @@
1
- import { SolFlag } from "./sol.js";
1
+ import { DPCM16, SolFlag } from "./sol.js";
2
2
  const dataBytes = (status) => ((status & 0xf0) === 0xc0 || (status & 0xf0) === 0xd0 ? 1 : 2);
3
3
  /** A channel stream: channel, voices, then delta-timed events with running status, then 0xFC. */
4
4
  function channelStream(c) {
@@ -51,19 +51,108 @@ export function writeSound(spec) {
51
51
  table.push(0xff);
52
52
  return Uint8Array.from([...header, ...table, ...body]);
53
53
  }
54
- /** A SOL clip: 16-bit samples at `rate`, uncompressed. */
55
- export function writeSol(samples, rate) {
56
- const out = new Uint8Array(13 + samples.length * 2);
54
+ /**
55
+ * A SOL clip of 16-bit samples at `rate`: uncompressed, or compressed with SOL's 16-bit DPCM
56
+ * (one byte a sample, half the size; what speech uses).
57
+ */
58
+ export function writeSol(samples, rate, compressed = false) {
59
+ const data = compressed ? encodeDpcm16(samples) : new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
60
+ const out = new Uint8Array(13 + data.length);
57
61
  const v = new DataView(out.buffer);
58
62
  out[0] = 0x8d; // audio
59
63
  out[1] = 11; // header bytes after these two
60
64
  out.set([0x53, 0x4f, 0x4c, 0], 2); // "SOL\0"
61
65
  v.setUint16(6, rate, true);
62
- out[8] = SolFlag.Bits16 | SolFlag.Signed;
63
- v.setUint32(9, samples.length * 2, true);
64
- samples.forEach((s, i) => v.setInt16(13 + i * 2, s, true));
66
+ out[8] = compressed ? SolFlag.Compressed | SolFlag.Bits16 : SolFlag.Bits16 | SolFlag.Signed;
67
+ v.setUint32(9, data.length, true);
68
+ if (compressed)
69
+ out.set(data, 13);
70
+ else
71
+ samples.forEach((s, i) => v.setInt16(13 + i * 2, s, true));
72
+ return out;
73
+ }
74
+ /**
75
+ * SOL's 16-bit DPCM: each byte steps from the last sample by DPCM16[byte & 0x7f], down if
76
+ * bit 7 is set. Each step is the one that lands nearest the sample, never past the 16-bit
77
+ * range (the decoder would wrap around).
78
+ */
79
+ export function encodeDpcm16(samples) {
80
+ const out = new Uint8Array(samples.length);
81
+ let last = 0;
82
+ for (let i = 0; i < samples.length; i++) {
83
+ const want = samples[i] - last, size = Math.abs(want);
84
+ let lo = 0, hi = DPCM16.length - 1;
85
+ while (lo < hi) {
86
+ const mid = (lo + hi) >> 1;
87
+ if (DPCM16[mid] < size)
88
+ lo = mid + 1;
89
+ else
90
+ hi = mid;
91
+ }
92
+ // lo is the first step at least as big; the one below may be nearer.
93
+ if (lo > 0 && size - DPCM16[lo - 1] < DPCM16[lo] - size)
94
+ lo--;
95
+ const down = want < 0;
96
+ const land = (k) => last + (down ? -DPCM16[k] : DPCM16[k]);
97
+ while (lo > 0 && (land(lo) > 32767 || land(lo) < -32768))
98
+ lo--;
99
+ out[i] = (down ? 0x80 : 0) | lo;
100
+ last = land(lo);
101
+ }
65
102
  return out;
66
103
  }
104
+ /**
105
+ * RESOURCE.AUD (SOL clips one after another, by module and key) and a map per module, in the
106
+ * layout AudioIndex reads: u32 offset of the module's first clip, then per clip u32
107
+ * big-endian noun<<24|verb<<16|cond<<8|seq and a u24 delta from the previous clip;
108
+ * 0xFFFFFFFF ends it.
109
+ *
110
+ * The index takes a clip's length from where the next one starts, so the last map also has
111
+ * an entry at the end of the file, under 0 0 0 0, which no message has (nouns and seqs
112
+ * start at 1); without it the last clip's length would be a guess.
113
+ */
114
+ export function writeSpeech(clips) {
115
+ const key = (c) => ((c.noun << 24) | (c.verb << 16) | (c.cond << 8) | c.seq) >>> 0;
116
+ const sorted = [...clips].sort((a, b) => a.module - b.module || key(a) - key(b));
117
+ const aud = new Uint8Array(sorted.reduce((n, c) => n + c.sol.length, 0));
118
+ const modules = [...new Set(sorted.map((c) => c.module))];
119
+ let at = 0;
120
+ const maps = modules.map((module, m) => {
121
+ const mine = sorted.filter((c) => c.module === module);
122
+ const last = m === modules.length - 1;
123
+ const data = new Uint8Array(4 + (mine.length + (last ? 1 : 0)) * 7 + 4);
124
+ const v = new DataView(data.buffer);
125
+ v.setUint32(0, at, true);
126
+ let prev = at, p = 4;
127
+ const entry = (k) => {
128
+ const delta = at - prev;
129
+ if (delta > 0xffffff)
130
+ throw new Error("speech clip too large (16 MB at most)");
131
+ v.setUint32(p, k, false);
132
+ data.set([delta & 0xff, (delta >> 8) & 0xff, delta >> 16], p + 4);
133
+ p += 7;
134
+ prev = at;
135
+ };
136
+ mine.forEach((c, i) => {
137
+ const where = `${c.module} ${c.noun} ${c.verb} ${c.cond} ${c.seq}`;
138
+ for (const [n, max] of [[c.noun, 255], [c.verb, 255], [c.cond, 255]])
139
+ if (n < 0 || n > max)
140
+ throw new Error(`speech for ${where}: noun, verb and cond are 0-255`);
141
+ if (c.seq < 1 || c.seq > 63)
142
+ throw new Error(`speech for ${where}: seq is 1-63`);
143
+ if (i && key(c) === key(mine[i - 1]))
144
+ throw new Error(`speech for ${where} is there twice`);
145
+ entry(key(c));
146
+ aud.set(c.sol, at);
147
+ at += c.sol.length;
148
+ });
149
+ if (last)
150
+ entry(0);
151
+ v.setUint32(p, 0xffffffff, false);
152
+ return { module, data };
153
+ });
154
+ return { aud, maps };
155
+ }
67
156
  /**
68
157
  * RESOURCE.SFX (the SOL clips, one after another) and its index, map 65535: per clip, u16
69
158
  * sound number and u24 offset from the previous clip; 0xFFFF ends it.