sci2-ts 0.3.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to this project. Versions follow [Semantic Versioning](https
4
4
  and the entries are generated from commit messages
5
5
  ([Conventional Commits](https://www.conventionalcommits.org/)) when a release is made.
6
6
 
7
+ ## [0.4.0](https://github.com/rlueder/sci-ts/compare/v0.3.0...v0.4.0) (2026-10-01)
8
+
9
+ ### Added
10
+
11
+ * **lib:** saving and restoring ([#2](https://github.com/rlueder/sci-ts/issues/2)) ([117ad9d](https://github.com/rlueder/sci-ts/commit/117ad9ddf4ca87d443d8b7143d66eef380dced9a))
12
+
7
13
  ## [0.3.0](https://github.com/rlueder/sci-ts/compare/v0.2.2...v0.3.0) (2026-10-01)
8
14
 
9
15
  ### Added
@@ -1,4 +1,4 @@
1
- import { EventType, ResourceManager, ResourceType, SciKey, SoundDevice, Vm, allKernels, audio, graphics, hostFiles, input, parseAdLibBank, readingTime, restoreGame, saves, snapshot, } from "../../../packages/sci/src/index.js";
1
+ import { EventType, GLOBAL_NAMES_VOCAB, ResourceManager, ResourceType, SciKey, SoundDevice, Vm, allKernels, audio, graphics, hostFiles, input, parseAdLibBank, readingTime, restoreGame, saves, snapshot, } from "../../../packages/sci/src/index.js";
2
2
  import { AdLibOutput } from "./adlib.js";
3
3
  import { WebAudioOutput } from "./audio.js";
4
4
  import { BASE, httpFiles } from "./files.js";
@@ -48,7 +48,7 @@ export class GameSession {
48
48
  const vm = new Vm(rm);
49
49
  vm.registerKernels(allKernels);
50
50
  // Saved games persist in this browser (IndexedDB) and appear in the game's Restore screen.
51
- saves(vm).store = await browserSaveStore(options.saveNamespace ?? [...(options.mods ?? [])].sort().join("+"));
51
+ saves(vm).store = await browserSaveStore(options.saveNamespace ?? defaultSaveNamespace(vm, rm, options.mods ?? []));
52
52
  if (options.debug)
53
53
  hostFiles(vm).add("18.scr");
54
54
  const sound = new WebAudioOutput(httpFiles, (name) => rm.file(name));
@@ -279,6 +279,16 @@ export class GameSession {
279
279
  });
280
280
  }
281
281
  }
282
+ /**
283
+ * Whose saves these are. Games built by sci-ts (they carry their globals' names, vocab 990)
284
+ * each get their own, by the game object's name, so games played from one site don't share
285
+ * save slots; Sierra's games keep the namespace they always had (their mods, if any).
286
+ */
287
+ function defaultSaveNamespace(vm, rm, mods) {
288
+ if (rm.has({ type: ResourceType.Vocab, number: GLOBAL_NAMES_VOCAB }))
289
+ return `game:${vm.object(vm.exportAddress(0, 0)).name}`;
290
+ return [...mods].sort().join("+");
291
+ }
282
292
  const KEY_CODES = {
283
293
  Enter: 13, Escape: 27, Backspace: 8, Tab: 9, " ": 32,
284
294
  ArrowUp: SciKey.Up, ArrowDown: SciKey.Down, ArrowLeft: SciKey.Left, ArrowRight: SciKey.Right,
@@ -1,5 +1,5 @@
1
1
  import {
2
- EventType, ResourceManager, ResourceType, SciKey, SoundDevice, Vm, allKernels, audio, graphics, hostFiles, input, parseAdLibBank, readingTime, restoreGame, saves, snapshot,
2
+ EventType, GLOBAL_NAMES_VOCAB, ResourceManager, ResourceType, SciKey, SoundDevice, Vm, allKernels, audio, graphics, hostFiles, input, parseAdLibBank, readingTime, restoreGame, saves, snapshot,
3
3
  type Frame, type VmSnapshot,
4
4
  } from "../../../packages/sci/src/index.ts";
5
5
  import { AdLibOutput } from "./adlib.ts";
@@ -76,7 +76,7 @@ export class GameSession {
76
76
  const vm = new Vm(rm);
77
77
  vm.registerKernels(allKernels);
78
78
  // Saved games persist in this browser (IndexedDB) and appear in the game's Restore screen.
79
- saves(vm).store = await browserSaveStore(options.saveNamespace ?? [...(options.mods ?? [])].sort().join("+"));
79
+ saves(vm).store = await browserSaveStore(options.saveNamespace ?? defaultSaveNamespace(vm, rm, options.mods ?? []));
80
80
  if (options.debug) hostFiles(vm).add("18.scr");
81
81
 
82
82
  const sound = new WebAudioOutput(httpFiles, (name) => rm.file(name));
@@ -294,6 +294,16 @@ export class GameSession {
294
294
  }
295
295
  }
296
296
 
297
+ /**
298
+ * Whose saves these are. Games built by sci-ts (they carry their globals' names, vocab 990)
299
+ * each get their own, by the game object's name, so games played from one site don't share
300
+ * save slots; Sierra's games keep the namespace they always had (their mods, if any).
301
+ */
302
+ function defaultSaveNamespace(vm: Vm, rm: ResourceManager, mods: string[]): string {
303
+ if (rm.has({ type: ResourceType.Vocab, number: GLOBAL_NAMES_VOCAB })) return `game:${vm.object(vm.exportAddress(0, 0)).name}`;
304
+ return [...mods].sort().join("+");
305
+ }
306
+
297
307
  const KEY_CODES: Record<string, number> = {
298
308
  Enter: 13, Escape: 27, Backspace: 8, Tab: 9, " ": 32,
299
309
  ArrowUp: SciKey.Up, ArrowDown: SciKey.Down, ArrowLeft: SciKey.Left, ArrowRight: SciKey.Right,
package/lib/992.sc ADDED
@@ -0,0 +1,173 @@
1
+ ;;; Saving, restoring and restarting: the game menu (Escape), F5 to save and F7 to restore.
2
+ (script 992)
3
+ (include "system.sh")
4
+
5
+ ;; A line to type into, for the interpreter's line editor (EditText): shown over the room
6
+ ;; until Enter, Escape or a click outside it, each of which is left for the next event.
7
+ (class EditField of Obj
8
+ (properties
9
+ x 0 y 0 z 0
10
+ view -1 loop 0 cel 0
11
+ priority 300 fixPriority 1
12
+ plane 0
13
+ bitmap 0
14
+ scaleSignal 0 scaleX 128 scaleY 128
15
+ text 0
16
+ font 0
17
+ fore 0
18
+ back 255
19
+ skip 254
20
+ mode 0
21
+ borderColor 0
22
+ nsLeft 0 nsTop 0 nsRight 0 nsBottom 0
23
+ textLeft 3 textTop 3 textRight 0 textBottom 0
24
+ width SAVE_DESCRIPTION) ; the most characters
25
+
26
+ ;; At (x, y), w pixels wide and one line of text tall.
27
+ (method (place theX theY w)
28
+ (= x theX)
29
+ (= y theY)
30
+ (= plane uiPlane)
31
+ (= font (textStyle font?))
32
+ (= fore (textStyle fore?))
33
+ (= back (textStyle back?))
34
+ (= borderColor (if (== (textStyle border?) -1) (textStyle fore?) else (textStyle border?)))
35
+ (= nsLeft x)
36
+ (= nsTop y)
37
+ (= nsRight (+ x w -1))
38
+ (= nsBottom (+ y (PointSize font) 5))
39
+ (= textRight (- w 4))
40
+ (= textBottom (- (- nsBottom nsTop) 3))))
41
+
42
+ ;; What the game menu, F5 and F7 open; (game save:), (game restore:) and (game showMenu:)
43
+ ;; open them from a script. A save is a snapshot of the whole game, named with a line the
44
+ ;; player types; only saves made by this build of the game are offered.
45
+ (class SaveRestore of Obj
46
+ (properties
47
+ mode 0 ; what the open menu is for: SR_MENU, SR_SAVE, SR_RESTORE, or SR_NAME
48
+ descriptions 0 ; GetSaveFiles: SAVE_DESCRIPTION characters a save, newest first
49
+ ids 0
50
+ count 0
51
+ field 0 ; the description being typed
52
+ slot 0) ; the save it goes into
53
+
54
+ (method (showMenu &tmp menu)
55
+ (= mode SR_MENU)
56
+ (= menu (Menu new:))
57
+ (menu
58
+ add: SR_SAVE "Save the game"
59
+ add: SR_RESTORE "Restore a game"
60
+ add: SR_RESTART "Start again"
61
+ add: 0 "Carry on"
62
+ show: self))
63
+
64
+ ;; The saves there are now.
65
+ (method (load)
66
+ (self release:)
67
+ (= descriptions (String ARRAY_NEW 0))
68
+ (= ids (Array ARRAY_NEW 0 0))
69
+ (= count (GetSaveFiles "" descriptions ids)))
70
+
71
+ (method (release)
72
+ (if descriptions (String ARRAY_FREE descriptions) (= descriptions 0))
73
+ (if ids (Array ARRAY_FREE ids) (= ids 0)))
74
+
75
+ ;; The description of the i-th save (a new string).
76
+ (method (description i &tmp s)
77
+ (= s (String ARRAY_NEW SAVE_DESCRIPTION))
78
+ (Array ARRAY_COPY s 0 descriptions (* i SAVE_DESCRIPTION) (- SAVE_DESCRIPTION 1))
79
+ (return s))
80
+
81
+ ;; One more than the highest save number.
82
+ (method (nextSlot &tmp i n id)
83
+ (= n 0)
84
+ (for ((= i 0)) (< i count) ((++ i))
85
+ (= id (Array ARRAY_AT ids i))
86
+ (if (>= id n) (= n (+ id 1))))
87
+ (return n))
88
+
89
+ ;; Menu values are save numbers plus one (0 is cancel).
90
+ (method (listSaves menu &tmp i)
91
+ (for ((= i 0)) (and (< i count) (< i SAVES_SHOWN)) ((++ i))
92
+ (menu add: (+ (Array ARRAY_AT ids i) 1) (self description: i))))
93
+
94
+ (method (save &tmp menu)
95
+ (game clearText:)
96
+ (self load:)
97
+ (= mode SR_SAVE)
98
+ (= menu ((Menu new:) y: 12 yourself:))
99
+ (menu add: (+ (self nextSlot:) 1) "A new saved game")
100
+ (self listSaves: menu)
101
+ (menu add: 0 "Cancel" show: self))
102
+
103
+ (method (restore &tmp menu)
104
+ (game clearText:)
105
+ (self load:)
106
+ (if (not count)
107
+ (narrator say: "There are no saved games yet.")
108
+ (return))
109
+ (= mode SR_RESTORE)
110
+ (= menu ((Menu new:) y: 12 yourself:))
111
+ (self listSaves: menu)
112
+ (menu add: 0 "Cancel" show: self))
113
+
114
+ (method (choose v)
115
+ (switch mode
116
+ (SR_MENU
117
+ (switch v
118
+ (SR_SAVE (self save:))
119
+ (SR_RESTORE (self restore:))
120
+ (SR_RESTART (RestartGame))))
121
+ (SR_SAVE (if v (self askDescription: (- v 1))))
122
+ (SR_RESTORE
123
+ (if v
124
+ ;; Returns only if it couldn't: the game starts again where the save left it.
125
+ (RestoreGame "" (- v 1) "")
126
+ (narrator say: "That saved game couldn't be restored.")))))
127
+
128
+ ;; Types the description of save n: the old one, or the room's number to start from.
129
+ (method (askDescription n &tmp i)
130
+ (= slot n)
131
+ (= field (EditField new:))
132
+ (field text: (String ARRAY_NEW SAVE_DESCRIPTION))
133
+ (for ((= i 0)) (< i count) ((++ i))
134
+ (if (== (Array ARRAY_AT ids i) n)
135
+ (Array ARRAY_COPY (field text?) 0 descriptions (* i SAVE_DESCRIPTION) (- SAVE_DESCRIPTION 1))))
136
+ (if (not (String STRING_LENGTH (field text?)))
137
+ (String STRING_FORMAT_INTO (field text?) "Room %d" curRoomNum))
138
+ (field place: 40 80 240)
139
+ (EditText field)
140
+ ;; How it ended (Enter, Escape, a click) is the next event: wait for it.
141
+ (= mode SR_NAME)
142
+ (= dialog self))
143
+
144
+ (method (handleEvent event)
145
+ (event claimed: TRUE)
146
+ (if (== mode SR_NAME)
147
+ (cond
148
+ ((and (== (event type?) EV_KEY_DOWN) (== (event message?) KEY_ENTER)) (self finishSave:))
149
+ ((or (== (event type?) EV_KEY_DOWN) (== (event type?) EV_MOUSE_DOWN)) (self endNaming:))))
150
+ (return TRUE))
151
+
152
+ (method (dismiss)
153
+ (self endNaming:))
154
+
155
+ (method (endNaming)
156
+ (if (== dialog self) (= dialog 0))
157
+ (= mode 0)
158
+ (if field
159
+ (String ARRAY_FREE (field text?))
160
+ (field dispose:)
161
+ (= field 0)))
162
+
163
+ (method (finishSave &tmp ok)
164
+ ;; Nothing of this menu goes into the save.
165
+ (if (== dialog self) (= dialog 0))
166
+ (= mode 0)
167
+ (= ok (SaveGame "" slot (field text?) ""))
168
+ (self endNaming: release:)
169
+ (narrator say: (if ok "Saved." else "The game couldn't be saved.")))
170
+
171
+ (method (dispose)
172
+ (self release:)
173
+ (super dispose:)))
package/lib/993.sc CHANGED
@@ -79,6 +79,9 @@
79
79
  (= icons 0))
80
80
  (if window (window dispose:) (= window 0)))
81
81
 
82
+ (method (dismiss)
83
+ (self hide:))
84
+
82
85
  (method (delete item)
83
86
  (if (== theItem item) (user useItem: 0))
84
87
  (super delete: item &rest)))
@@ -119,6 +122,9 @@
119
122
  (self close:))
120
123
  (return TRUE))
121
124
 
125
+ (method (dismiss)
126
+ (self close:))
127
+
122
128
  (method (close &tmp c)
123
129
  (if (== dialog self) (= dialog 0))
124
130
  (if picture (picture dispose:) (= picture 0))
package/lib/994.sc CHANGED
@@ -42,6 +42,19 @@
42
42
  (= client 0)
43
43
  (if c (c cue:)))))
44
44
 
45
+ ;; After a restore nothing is playing: a sound that loops forever starts again; one that
46
+ ;; was playing once is over (and whoever was waiting for it hears so).
47
+ (method (replay &tmp c)
48
+ (if handle
49
+ (= handle 0)
50
+ (= nodePtr 0)
51
+ (if (== loop -1)
52
+ (self play: client)
53
+ else
54
+ (= c client)
55
+ (= client 0)
56
+ (if c (c cue:)))))
57
+
45
58
  (method (dispose)
46
59
  (DoSound SND_STOP self)
47
60
  (DoSound SND_DISPOSE self)
package/lib/995.sc CHANGED
@@ -20,6 +20,7 @@
20
20
  ;; A game with its own sets them in its init: (user walkCursor: 261 waitCursor: 265 ...).
21
21
  ;; I or Tab opens the inventory; an item picked there is used like a verb (V_ITEM), with
22
22
  ;; its own cursor, until right-click goes back to walking.
23
+ ;; Escape opens the game menu (save, restore, start again); F5 saves and F7 restores.
23
24
  (class User of Obj
24
25
  (properties
25
26
  verb V_WALK
@@ -86,6 +87,11 @@
86
87
  (dialog handleEvent: event)
87
88
  (return))
88
89
  (if (not canInput) (return))
90
+ (if (== (event type?) EV_KEY_DOWN)
91
+ (switch (event message?)
92
+ (KEY_ESCAPE (event claimed: TRUE) (game showMenu:) (return))
93
+ (KEY_F5 (event claimed: TRUE) (game save:) (return))
94
+ (KEY_F7 (event claimed: TRUE) (game restore:) (return))))
89
95
  (if (and (== (event type?) EV_KEY_DOWN)
90
96
  (or (== (event message?) KEY_TAB) (== (event message?) KEY_i) (== (event message?) KEY_I)))
91
97
  (event claimed: TRUE)
package/lib/996.sc CHANGED
@@ -290,6 +290,10 @@
290
290
  (c choose: v))
291
291
  (return TRUE))
292
292
 
293
+ ;; Closed without a choice.
294
+ (method (dismiss)
295
+ (self dispose:))
296
+
293
297
  (method (dispose)
294
298
  (if (== dialog self) (= dialog 0))
295
299
  (if items
package/lib/998.sc CHANGED
@@ -20,7 +20,8 @@
20
20
  ;; The game: sets everything up, then runs one cycle after another until `quit`.
21
21
  (class Game of Obj
22
22
  (properties
23
- script 0)
23
+ script 0
24
+ saver 0) ; the SaveRestore behind the game menu
24
25
 
25
26
  (method (init)
26
27
  (= game self)
@@ -44,6 +45,38 @@
44
45
  (while (not quit)
45
46
  (self doit:)))
46
47
 
48
+ ;; After a restore (RestoreGame), the interpreter starts the game here, not at play: the
49
+ ;; game is as it was saved, but nothing is on screen and nothing is playing. Put the
50
+ ;; planes and the cast back and start looping sounds again, then carry on.
51
+ (method (replay)
52
+ (uiPlane init:)
53
+ (if curRoom ((curRoom plane?) init:))
54
+ (cast eachElementDo: #replay)
55
+ (sounds eachElementDo: #replay)
56
+ (user cursor: -1)
57
+ (while (not quit)
58
+ (self doit:)))
59
+
60
+ ;; The game menu (Escape): save, restore, start again.
61
+ (method (showMenu)
62
+ (self clearText:)
63
+ ((self saveRestore:) showMenu:))
64
+
65
+ (method (save)
66
+ ((self saveRestore:) save:))
67
+
68
+ (method (restore)
69
+ ((self saveRestore:) restore:))
70
+
71
+ (method (saveRestore)
72
+ (if (not saver) (= saver (SaveRestore new:)))
73
+ (return saver))
74
+
75
+ ;; Takes down a line being shown and whatever is open (a menu, the inventory, a close-up).
76
+ (method (clearText)
77
+ (if talking (talking clear:))
78
+ (if dialog (dialog dismiss:)))
79
+
47
80
  ;; One cycle: scripts, the room, everything on screen, text, drawing, then input.
48
81
  (method (doit)
49
82
  (++ gameTime)
@@ -59,8 +92,7 @@
59
92
  ;; Leaves the current room for room n (its script's export 0).
60
93
  (method (newRoom n &tmp old)
61
94
  (= newRoomNum 0)
62
- (if talking (talking clear:))
63
- (if dialog (dialog dispose:))
95
+ (self clearText:)
64
96
  (if curRoom
65
97
  (= old curRoomNum)
66
98
  (curRoom dispose:)
@@ -239,6 +271,10 @@
239
271
  (method (roomDisposed)
240
272
  (self dispose:))
241
273
 
274
+ ;; Back on screen after a restore.
275
+ (method (replay)
276
+ (if (not (& signal SIG_HIDDEN)) (AddScreenItem self)))
277
+
242
278
  (method (dispose)
243
279
  (if (not (& signal SIG_HIDDEN)) (DeleteScreenItem self))
244
280
  (cast delete: self)
package/lib/system.sh CHANGED
@@ -24,6 +24,16 @@
24
24
  (define REMAP_OFF 0)
25
25
  (define REMAP_BY_PERCENT 2)
26
26
 
27
+ ;; Saved games: a description is up to SAVE_DESCRIPTION - 1 characters; the menus show the
28
+ ;; newest SAVES_SHOWN. What SaveRestore's open menu is for:
29
+ (define SAVE_DESCRIPTION 36)
30
+ (define SAVES_SHOWN 8)
31
+ (define SR_MENU 1)
32
+ (define SR_SAVE 2)
33
+ (define SR_RESTORE 3)
34
+ (define SR_RESTART 4)
35
+ (define SR_NAME 5)
36
+
27
37
  ;; Event types, as GetEvent reports them.
28
38
  (define EV_NULL 0)
29
39
  (define EV_MOUSE_DOWN 1)
@@ -32,6 +42,10 @@
32
42
 
33
43
  ;; Keys (an event's message).
34
44
  (define KEY_TAB 9)
45
+ (define KEY_ENTER 13)
46
+ (define KEY_ESCAPE 27)
47
+ (define KEY_F5 $3f00)
48
+ (define KEY_F7 $4100)
35
49
  (define KEY_I 73)
36
50
  (define KEY_i 105)
37
51
  (define EV_ALL $7fff)
@@ -53,6 +67,8 @@
53
67
  (define ARRAY_AT_PUT 3)
54
68
  (define ARRAY_FREE 4)
55
69
  (define STRING_LENGTH 10)
70
+ (define STRING_FORMAT_INTO 12)
71
+ (define ARRAY_COPY 6)
56
72
 
57
73
  ;; Bitmap subfunctions.
58
74
  (define BITMAP_DISPOSE 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sci2-ts",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "An SCI2 adventure game engine in TypeScript, and the tools to make games with it",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,6 +3,6 @@ export { graphics, Graphics, type Frame, SCREEN_WIDTH, SCREEN_HEIGHT, ShowStyle,
3
3
  export { input, Input, EventType, SciKey, hostFiles, type SciEvent } from "./system.ts";
4
4
  export { audio, AudioState, readingTime } from "./audio.ts";
5
5
  export { paletteEffects, PaletteEffects } from "./palette.ts";
6
- export { saves, restoreGame, MemorySaveStore, type SaveStore, type SaveInfo } from "./saves.ts";
6
+ export { saves, restoreGame, scriptsFingerprint, MemorySaveStore, type SaveStore, type SaveInfo } from "./saves.ts";
7
7
  export declare const allKernels: Record<string, KernelFn>;
8
8
  export { stringHelpers } from "./arrays.ts";
@@ -12,7 +12,7 @@ export { graphics, Graphics, SCREEN_WIDTH, SCREEN_HEIGHT, ShowStyle, frameRgb }
12
12
  export { input, Input, EventType, SciKey, hostFiles } from "./system.js";
13
13
  export { audio, AudioState, readingTime } from "./audio.js";
14
14
  export { paletteEffects, PaletteEffects } from "./palette.js";
15
- export { saves, restoreGame, MemorySaveStore } from "./saves.js";
15
+ export { saves, restoreGame, scriptsFingerprint, MemorySaveStore } from "./saves.js";
16
16
  export const allKernels = {
17
17
  ...coreKernels,
18
18
  ...mathKernels,
@@ -14,7 +14,7 @@ export { graphics, Graphics, type Frame, SCREEN_WIDTH, SCREEN_HEIGHT, ShowStyle,
14
14
  export { input, Input, EventType, SciKey, hostFiles, type SciEvent } from "./system.ts";
15
15
  export { audio, AudioState, readingTime } from "./audio.ts";
16
16
  export { paletteEffects, PaletteEffects } from "./palette.ts";
17
- export { saves, restoreGame, MemorySaveStore, type SaveStore, type SaveInfo } from "./saves.ts";
17
+ export { saves, restoreGame, scriptsFingerprint, MemorySaveStore, type SaveStore, type SaveInfo } from "./saves.ts";
18
18
 
19
19
  export const allKernels: Record<string, KernelFn> = {
20
20
  ...coreKernels,
@@ -5,6 +5,11 @@ export interface SaveInfo {
5
5
  description: string;
6
6
  version: string;
7
7
  date: number;
8
+ /**
9
+ * The scripts it was made with (scriptsFingerprint). A save is a snapshot of running
10
+ * scripts, so it only restores into the same ones; saves from before this was kept have none.
11
+ */
12
+ scripts?: string;
8
13
  }
9
14
  /** Where saves live. Synchronous because kernels are; hosts preload and write through. */
10
15
  export interface SaveStore {
@@ -32,7 +37,10 @@ export declare class MemorySaveStore implements SaveStore {
32
37
  declare class SaveState {
33
38
  store: SaveStore;
34
39
  restarting: number;
40
+ fingerprint: string | undefined;
35
41
  }
42
+ /** A hash (FNV-1a) of every script and heap resource: which scripts a save belongs to. */
43
+ export declare function scriptsFingerprint(vm: Vm): string;
36
44
  export declare const saves: (vm: Vm) => SaveState;
37
45
  /**
38
46
  * Loads a saved game the way SSCI's RestoreGame does: host state (planes, screen items,
@@ -1,3 +1,4 @@
1
+ import { ResourceType } from "../../resource/types.js";
1
2
  import { restore, snapshot } from "../savegame.js";
2
3
  import { toSigned } from "../value.js";
3
4
  import { stringHelpers } from "./arrays.js";
@@ -26,7 +27,25 @@ const DESCRIPTION_SIZE = 36;
26
27
  class SaveState {
27
28
  store = new MemorySaveStore();
28
29
  restarting = Restarting.None;
30
+ fingerprint;
29
31
  }
32
+ /** A hash (FNV-1a) of every script and heap resource: which scripts a save belongs to. */
33
+ export function scriptsFingerprint(vm) {
34
+ const s = saves(vm);
35
+ if (s.fingerprint === undefined) {
36
+ let h = 0x811c9dc5;
37
+ const resources = vm.resources.list().filter((r) => r.type === ResourceType.Script || r.type === ResourceType.Heap);
38
+ resources.sort((a, b) => a.type - b.type || a.number - b.number);
39
+ for (const r of resources) {
40
+ for (const b of [r.type, r.number & 0xff, r.number >> 8, ...vm.resources.loadSync(r).data])
41
+ h = Math.imul(h ^ b, 0x01000193) >>> 0;
42
+ }
43
+ s.fingerprint = h.toString(16).padStart(8, "0");
44
+ }
45
+ return s.fingerprint;
46
+ }
47
+ /** Whether a save can be restored into the scripts running now. */
48
+ const fits = (vm, info) => info.scripts === undefined || info.scripts === scriptsFingerprint(vm);
30
49
  const stateOf = new WeakMap();
31
50
  export const saves = (vm) => {
32
51
  let s = stateOf.get(vm);
@@ -66,6 +85,7 @@ export const saveKernels = {
66
85
  description: stringHelpers.str(vm, description),
67
86
  version: stringHelpers.str(vm, version),
68
87
  date: Date.now(),
88
+ scripts: scriptsFingerprint(vm),
69
89
  };
70
90
  saves(vm).store.put(info, snapshot(vm));
71
91
  return 1;
@@ -74,7 +94,7 @@ export const saveKernels = {
74
94
  // the saved state via `theGame replay`, as in the original interpreter.
75
95
  RestoreGame: (vm, [, id = 0]) => {
76
96
  const save = saves(vm).store.get(toSigned(id));
77
- if (!save)
97
+ if (!save || !fits(vm, save.info))
78
98
  return 0;
79
99
  restoreGame(vm, save.snapshot);
80
100
  return 1;
@@ -97,7 +117,8 @@ export const saveKernels = {
97
117
  // GetSaveFiles(gameName, descriptions, ids): fixed 36-byte description slots and a
98
118
  // 0-terminated id list, newest first.
99
119
  GetSaveFiles: (vm, [, descriptions = 0, ids = 0]) => {
100
- const list = saves(vm).store.list();
120
+ // Only saves these scripts can restore.
121
+ const list = saves(vm).store.list().filter((info) => fits(vm, info));
101
122
  const desc = stringHelpers.resolve(vm, descriptions);
102
123
  const idArray = stringHelpers.resolve(vm, ids);
103
124
  if (desc) {
@@ -115,7 +136,7 @@ export const saveKernels = {
115
136
  // CheckSaveGame(gameName, id, version): exists and was made by this game version.
116
137
  CheckSaveGame: (vm, [, id = 0, version = 0]) => {
117
138
  const save = saves(vm).store.get(toSigned(id));
118
- return save && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
139
+ return save && fits(vm, save.info) && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
119
140
  },
120
141
  GetSaveDir: (vm) => stringHelpers.newString(vm, "SAVES"),
121
142
  MakeSaveCatName: (vm, [dst = 0]) => stringHelpers.writeString(vm, dst, "QG4CDSG.CAT"),
@@ -1,3 +1,4 @@
1
+ import { ResourceType } from "../../resource/types.ts";
1
2
  import { restore, snapshot, type VmSnapshot } from "../savegame.ts";
2
3
  import type { KernelFn, Vm } from "../vm.ts";
3
4
  import { toSigned } from "../value.ts";
@@ -12,6 +13,11 @@ export interface SaveInfo {
12
13
  description: string;
13
14
  version: string;
14
15
  date: number;
16
+ /**
17
+ * The scripts it was made with (scriptsFingerprint). A save is a snapshot of running
18
+ * scripts, so it only restores into the same ones; saves from before this was kept have none.
19
+ */
20
+ scripts?: string;
15
21
  }
16
22
 
17
23
  /** Where saves live. Synchronous because kernels are; hosts preload and write through. */
@@ -47,8 +53,27 @@ const DESCRIPTION_SIZE = 36;
47
53
  class SaveState {
48
54
  store: SaveStore = new MemorySaveStore();
49
55
  restarting: number = Restarting.None;
56
+ fingerprint: string | undefined;
50
57
  }
51
58
 
59
+ /** A hash (FNV-1a) of every script and heap resource: which scripts a save belongs to. */
60
+ export function scriptsFingerprint(vm: Vm): string {
61
+ const s = saves(vm);
62
+ if (s.fingerprint === undefined) {
63
+ let h = 0x811c9dc5;
64
+ const resources = vm.resources.list().filter((r) => r.type === ResourceType.Script || r.type === ResourceType.Heap);
65
+ resources.sort((a, b) => a.type - b.type || a.number - b.number);
66
+ for (const r of resources) {
67
+ for (const b of [r.type, r.number & 0xff, r.number >> 8, ...vm.resources.loadSync(r).data]) h = Math.imul(h ^ b, 0x01000193) >>> 0;
68
+ }
69
+ s.fingerprint = h.toString(16).padStart(8, "0");
70
+ }
71
+ return s.fingerprint;
72
+ }
73
+
74
+ /** Whether a save can be restored into the scripts running now. */
75
+ const fits = (vm: Vm, info: SaveInfo) => info.scripts === undefined || info.scripts === scriptsFingerprint(vm);
76
+
52
77
  const stateOf = new WeakMap<Vm, SaveState>();
53
78
  export const saves = (vm: Vm): SaveState => {
54
79
  let s = stateOf.get(vm);
@@ -90,6 +115,7 @@ export const saveKernels: Record<string, KernelFn> = {
90
115
  description: stringHelpers.str(vm, description),
91
116
  version: stringHelpers.str(vm, version),
92
117
  date: Date.now(),
118
+ scripts: scriptsFingerprint(vm),
93
119
  };
94
120
  saves(vm).store.put(info, snapshot(vm));
95
121
  return 1;
@@ -99,7 +125,7 @@ export const saveKernels: Record<string, KernelFn> = {
99
125
  // the saved state via `theGame replay`, as in the original interpreter.
100
126
  RestoreGame: (vm, [, id = 0]) => {
101
127
  const save = saves(vm).store.get(toSigned(id));
102
- if (!save) return 0;
128
+ if (!save || !fits(vm, save.info)) return 0;
103
129
  restoreGame(vm, save.snapshot);
104
130
  return 1;
105
131
  },
@@ -123,7 +149,8 @@ export const saveKernels: Record<string, KernelFn> = {
123
149
  // GetSaveFiles(gameName, descriptions, ids): fixed 36-byte description slots and a
124
150
  // 0-terminated id list, newest first.
125
151
  GetSaveFiles: (vm, [, descriptions = 0, ids = 0]) => {
126
- const list = saves(vm).store.list();
152
+ // Only saves these scripts can restore.
153
+ const list = saves(vm).store.list().filter((info) => fits(vm, info));
127
154
  const desc = stringHelpers.resolve(vm, descriptions);
128
155
  const idArray = stringHelpers.resolve(vm, ids);
129
156
  if (desc) {
@@ -140,7 +167,7 @@ export const saveKernels: Record<string, KernelFn> = {
140
167
  // CheckSaveGame(gameName, id, version): exists and was made by this game version.
141
168
  CheckSaveGame: (vm, [, id = 0, version = 0]) => {
142
169
  const save = saves(vm).store.get(toSigned(id));
143
- return save && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
170
+ return save && fits(vm, save.info) && save.info.version === stringHelpers.str(vm, version) ? 1 : 0;
144
171
  },
145
172
 
146
173
  GetSaveDir: (vm) => stringHelpers.newString(vm, "SAVES"),