@frotzinc/zcode-ui 0.1.0 → 0.2.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 +16 -0
- package/app/components/Disassembly.vue +15 -2
- package/app/components/GlobalsView.vue +10 -2
- package/app/components/HexPane.vue +6 -4
- package/app/components/MapView.vue +27 -4
- package/app/components/ObjectTree.vue +2 -1
- package/app/composables/useSymbols.ts +5 -1
- package/app/utils/live.ts +5 -17
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@frotzinc/zcode-ui`. Versions follow [Semantic Versioning](https://semver.org/). Part of [frotz, Inc.](https://frotzinc.com).
|
|
4
4
|
|
|
5
|
+
## [0.2.0] - 2026-11-08
|
|
6
|
+
|
|
7
|
+
The views light a running machine (frotztrace Phase 4). Without `live` or `breakpoints`, each renders exactly as before.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`Disassembly`**: with `live`, the PC's line is marked (with "current instruction" for screen readers); with `breakpoints`, a gutter button per line (`aria-pressed`) emits `toggleBreakpoint`.
|
|
11
|
+
- **`GlobalsView`**: with `live`, a "Now" column read from the running machine's memory, and the globals changed since the last stop marked "changed".
|
|
12
|
+
- **`MapView`**: with `live`, the player's room (`real_location`, or `location`) is marked and the visited rooms shaded, with "you are here" and "visited" in the list.
|
|
13
|
+
- **`useSymbols().symbolsText`**: the debug file's text as read, so frotzlens can hand it to frotztrace.
|
|
14
|
+
- **`ObjectTree`** and **`HexPane`**: with `live`, what changed is lit, with a "changed" badge or count as its text equivalent. Give `ObjectTree` a story whose objects are read live (`zcode`'s `liveTables`) to show the tree as it is now.
|
|
15
|
+
|
|
16
|
+
## [0.1.1] - 2026-11-08
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `MachineView` is now the Debugger's own type from `@frotzinc/zmachine` (0.2.0), re-exported; the same shape as the placeholder it replaces. `@frotzinc/zmachine` is a new dependency, for the type.
|
|
20
|
+
|
|
5
21
|
## [0.1.0] - 2026-11-08
|
|
6
22
|
|
|
7
23
|
The first release: frotzlens's story views, moved out of the app (and relicensed MIT by their author) so frotztrace can show a running story with the same components.
|
|
@@ -7,7 +7,11 @@ import type { MachineView } from '../utils/live'
|
|
|
7
7
|
* it uses is a link, and a branch or jump target moves focus to its line. `live` is unused until
|
|
8
8
|
* frotztrace's Phase 4, when it marks the program counter.
|
|
9
9
|
*/
|
|
10
|
-
const props = defineProps<{ view: RoutineView; address: number; live?: MachineView }>()
|
|
10
|
+
const props = defineProps<{ view: RoutineView; address: number; live?: MachineView; breakpoints?: ReadonlySet<number> }>()
|
|
11
|
+
/** frotztrace: a line's gutter button was pressed. */
|
|
12
|
+
const emit = defineEmits<{ toggleBreakpoint: [address: number] }>()
|
|
13
|
+
/** The gutter (the PC, breakpoints) only when there's something to show: frotzlens's view is unchanged. */
|
|
14
|
+
const gutter = computed(() => !!props.live || !!props.breakpoints)
|
|
11
15
|
|
|
12
16
|
// Labels: a branch or jump target scrolls to its line and focuses it (the route stays the routine).
|
|
13
17
|
function jump(target: number) {
|
|
@@ -29,7 +33,16 @@ function jump(target: number) {
|
|
|
29
33
|
:key="l.address"
|
|
30
34
|
tabindex="-1"
|
|
31
35
|
class="whitespace-pre rounded focus:bg-elevated"
|
|
32
|
-
|
|
36
|
+
:class="live?.pc === l.address ? 'bg-elevated font-semibold' : ''"
|
|
37
|
+
><span v-if="gutter" class="inline-flex w-12 items-center gap-1"><button
|
|
38
|
+
v-if="breakpoints"
|
|
39
|
+
type="button"
|
|
40
|
+
class="size-4 rounded-full border border-default leading-none"
|
|
41
|
+
:class="breakpoints.has(l.address) ? 'bg-[var(--frotz-lamp)]' : 'hover:bg-elevated'"
|
|
42
|
+
:aria-pressed="breakpoints.has(l.address)"
|
|
43
|
+
:aria-label="`Breakpoint at ${hex(l.address)}`"
|
|
44
|
+
@click="emit('toggleBreakpoint', l.address)"
|
|
45
|
+
/><span v-if="live?.pc === l.address" class="text-lamp" aria-hidden="true">▶</span><span v-if="live?.pc === l.address" class="sr-only">current instruction</span></span><span class="text-muted">{{ `${hex(l.address).padStart(5)} ` }}</span><span class="text-lamp">{{ (l.label ? `${l.label}:` : '').padEnd(7) }}</span><template v-for="(p, j) in l.pieces" :key="j"><a v-if="p.id" :href="idRoute(p.id)" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid">{{ p.text }}</a><a v-else-if="p.target !== undefined" :href="`#routine/${hex(address)}`" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid" :aria-label="`${p.text}, the instruction at ${hex(p.target)}`" @click.prevent="jump(p.target)">{{ p.text }}</a><span v-else>{{ p.text }}</span></template></div>
|
|
33
46
|
</div>
|
|
34
47
|
</div>
|
|
35
48
|
</template>
|
|
@@ -9,12 +9,15 @@ const number = computed(() => (props.id && /^\d+$/.test(props.id) ? Number(props
|
|
|
9
9
|
const global = computed(() => (number.value === undefined ? undefined : props.story.globals[number.value]))
|
|
10
10
|
|
|
11
11
|
const filter = ref('')
|
|
12
|
+
/** Live (frotztrace): the value now, from the running machine's memory. */
|
|
13
|
+
const nowOf = (address: number) => (props.live ? (props.live.memory[address]! << 8) | props.live.memory[address + 1]! : undefined)
|
|
14
|
+
|
|
12
15
|
const rows = computed(() => {
|
|
13
16
|
const s = props.story
|
|
14
17
|
const q = filter.value.trim().toLowerCase()
|
|
15
18
|
return s.globals
|
|
16
19
|
// Uses: each place an instruction reads or writes it, as the detail's "Used by" counts them.
|
|
17
|
-
.map(g => ({ g, name: globalName(g.number), txd: `G${hex(g.number, 2)}`, uses: s.xrefs[g.id]?.from.length ?? 0 }))
|
|
20
|
+
.map(g => ({ g, name: globalName(g.number), txd: `G${hex(g.number, 2)}`, uses: s.xrefs[g.id]?.from.length ?? 0, now: nowOf(g.address), changed: !!props.live?.changed.globals.has(g.number) }))
|
|
18
21
|
.filter(r => !q || r.name.toLowerCase().includes(q) || r.txd.toLowerCase() === q || hex(r.g.value).includes(q.replace(/^#|0x/, '')))
|
|
19
22
|
})
|
|
20
23
|
|
|
@@ -41,15 +44,20 @@ const asText = () =>
|
|
|
41
44
|
<th v-if="activeSymbols" scope="col" class="px-4 py-2 font-semibold">Number</th>
|
|
42
45
|
<th scope="col" class="px-4 py-2 font-semibold">Address</th>
|
|
43
46
|
<th scope="col" class="px-4 py-2 font-semibold">Starts as</th>
|
|
47
|
+
<th v-if="live" scope="col" class="px-4 py-2 font-semibold">Now</th>
|
|
44
48
|
<th scope="col" class="px-4 py-2 text-right font-semibold">Used by</th>
|
|
45
49
|
</tr>
|
|
46
50
|
</thead>
|
|
47
51
|
<tbody>
|
|
48
|
-
<tr v-for="r in rows" :key="r.g.number" class="border-t border-default">
|
|
52
|
+
<tr v-for="r in rows" :key="r.g.number" class="border-t border-default" :class="r.changed ? 'bg-elevated' : ''">
|
|
49
53
|
<td class="data px-4 py-1.5"><a :href="`#global/${r.g.number}`" class="text-lamp hover:underline">{{ r.name }}</a></td>
|
|
50
54
|
<td v-if="activeSymbols" class="data px-4 py-1.5 text-muted">{{ r.txd }}</td>
|
|
51
55
|
<td class="data px-4 py-1.5 text-muted">{{ hex(r.g.address) }}</td>
|
|
52
56
|
<td class="data px-4 py-1.5">{{ hex(r.g.value) }} <span class="text-muted">({{ r.g.value }})</span></td>
|
|
57
|
+
<td v-if="live" class="data px-4 py-1.5" :class="r.changed ? 'font-semibold text-lamp' : ''">
|
|
58
|
+
{{ hex(r.now!) }} <span class="text-muted">({{ r.now }})</span>
|
|
59
|
+
<span v-if="r.changed" class="ml-2 rounded-full border border-default px-1.5 font-sans text-xs text-lamp">changed</span>
|
|
60
|
+
</td>
|
|
53
61
|
<td class="data px-4 py-1.5 text-right">{{ r.uses }}</td>
|
|
54
62
|
</tr>
|
|
55
63
|
</tbody>
|
|
@@ -4,8 +4,8 @@ import type { MachineView } from '../utils/live'
|
|
|
4
4
|
/**
|
|
5
5
|
* A hex view of bytes, a page of rows at a time so a large file stays quick, with "Go to address"
|
|
6
6
|
* and paging. `top` (v-model) is the first row's address. `notes` labels what begins in a row;
|
|
7
|
-
* `inRange` dims the bytes outside the part being shown. `live`
|
|
8
|
-
*
|
|
7
|
+
* `inRange` dims the bytes outside the part being shown. `live` (frotztrace) lights the bytes the
|
|
8
|
+
* running machine changed since it last stopped; pass the live memory as `bytes`.
|
|
9
9
|
*/
|
|
10
10
|
const props = defineProps<{
|
|
11
11
|
bytes: Uint8Array
|
|
@@ -38,6 +38,8 @@ const rows = computed(() => {
|
|
|
38
38
|
return out
|
|
39
39
|
})
|
|
40
40
|
const inPart = (a: number) => props.inRange?.(a) ?? true
|
|
41
|
+
/** Live: how many of the row's bytes changed (the text equivalent of their highlight). */
|
|
42
|
+
const changedIn = (at: number) => { let n = 0; for (let i = 0; i < 16; i++) if (props.live?.changed.addresses.has(at + i)) n++; return n }
|
|
41
43
|
|
|
42
44
|
const jump = ref('')
|
|
43
45
|
const jumpError = ref('')
|
|
@@ -82,9 +84,9 @@ function onJump() {
|
|
|
82
84
|
<tbody>
|
|
83
85
|
<tr v-for="r in rows" :key="r.at">
|
|
84
86
|
<th scope="row" class="px-4 text-left font-normal text-muted">{{ hex(r.at, 5) }}</th>
|
|
85
|
-
<td class="whitespace-pre px-2"><template v-for="(b, i) in r.bytes" :key="i"><span :class="inPart(r.at + i) ? 'text-highlighted' : 'text-muted'">{{ hex(b, 2) }}</span>{{ i === 7 ? ' ' : i < 15 ? ' ' : '' }}</template></td>
|
|
87
|
+
<td class="whitespace-pre px-2"><template v-for="(b, i) in r.bytes" :key="i"><span :class="live?.changed.addresses.has(r.at + i) ? 'rounded bg-[var(--frotz-lamp)] text-[var(--frotz-ground)]' : inPart(r.at + i) ? 'text-highlighted' : 'text-muted'">{{ hex(b, 2) }}</span>{{ i === 7 ? ' ' : i < 15 ? ' ' : '' }}</template></td>
|
|
86
88
|
<td class="whitespace-pre px-4 text-muted">{{ r.text }}</td>
|
|
87
|
-
<td class="whitespace-nowrap px-4 font-sans text-xs text-lamp">{{ r.notes.join(' · ') }}</td>
|
|
89
|
+
<td class="whitespace-nowrap px-4 font-sans text-xs text-lamp">{{ [...r.notes, ...(changedIn(r.at) ? [`${changedIn(r.at)} changed`] : [])].join(' · ') }}</td>
|
|
88
90
|
</tr>
|
|
89
91
|
</tbody>
|
|
90
92
|
</table>
|
|
@@ -58,6 +58,27 @@ const exitIndex = computed(() => {
|
|
|
58
58
|
})
|
|
59
59
|
const exitsFrom = (n: number) => exitIndex.value.get(n) ?? []
|
|
60
60
|
const LINK = 'text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Live (frotztrace): the player's room and the rooms visited (the `visited` attribute), as they are
|
|
64
|
+
* now. The room is `real_location` when the library has it: in the dark, `location` is the
|
|
65
|
+
* darkness object, not a room.
|
|
66
|
+
*/
|
|
67
|
+
const here = computed(() => {
|
|
68
|
+
const s = symbols.value
|
|
69
|
+
if (!props.live || !s) return undefined
|
|
70
|
+
const named = (name: string) => Object.entries(s.globals).find(([, n]) => n === name)?.[0]
|
|
71
|
+
const g = named('real_location') ?? named('location')
|
|
72
|
+
if (g === undefined) return undefined
|
|
73
|
+
const a = props.story.header.globals + Number(g) * 2
|
|
74
|
+
return (props.live.memory[a]! << 8) | props.live.memory[a + 1]!
|
|
75
|
+
})
|
|
76
|
+
const visited = computed(() => {
|
|
77
|
+
const s = symbols.value
|
|
78
|
+
const attr = s && Object.entries(s.attributes).find(([, n]) => n === 'visited')?.[0]
|
|
79
|
+
if (!props.live || attr === undefined) return new Set<number>()
|
|
80
|
+
return new Set(props.story.objects.objects.filter(o => o.attributes.includes(Number(attr))).map(o => o.number))
|
|
81
|
+
})
|
|
61
82
|
const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
|
|
62
83
|
</script>
|
|
63
84
|
|
|
@@ -109,11 +130,11 @@ const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
|
|
|
109
130
|
:width="W"
|
|
110
131
|
:height="H"
|
|
111
132
|
rx="8"
|
|
112
|
-
fill="var(--ui-bg-elevated)"
|
|
113
|
-
:stroke="r.number === graph.start ? 'var(--frotz-lamp-text)' : 'var(--ui-border-accented)'"
|
|
114
|
-
:stroke-width="r.number === graph.start ? 3 : 1.5"
|
|
133
|
+
:fill="r.number === here ? 'var(--frotz-lamp)' : visited.has(r.number) ? 'var(--ui-bg-accented)' : 'var(--ui-bg-elevated)'"
|
|
134
|
+
:stroke="r.number === here || r.number === graph.start ? 'var(--frotz-lamp-text)' : 'var(--ui-border-accented)'"
|
|
135
|
+
:stroke-width="r.number === here ? 4 : r.number === graph.start ? 3 : 1.5"
|
|
115
136
|
/>
|
|
116
|
-
<text :x="r.col * CELL" :y="r.row * CELL + 5" text-anchor="middle" font-size="14" fill="var(--ui-text-highlighted)">{{ short(name(r.number)) }}</text>
|
|
137
|
+
<text :x="r.col * CELL" :y="r.row * CELL + 5" text-anchor="middle" font-size="14" :fill="r.number === here ? 'var(--frotz-ground)' : 'var(--ui-text-highlighted)'">{{ short(name(r.number)) }}</text>
|
|
117
138
|
</a>
|
|
118
139
|
</svg>
|
|
119
140
|
</div>
|
|
@@ -124,6 +145,8 @@ const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
|
|
|
124
145
|
<li v-for="r in graph.rooms" :key="r.number">
|
|
125
146
|
<a :href="`#object/${r.number}`" class="font-semibold text-lamp hover:underline">{{ name(r.number) }}</a>
|
|
126
147
|
<span v-if="r.number === graph.start" class="ml-2 text-xs text-muted">where the game starts</span>
|
|
148
|
+
<span v-if="r.number === here" class="ml-2 rounded-full border border-default px-1.5 text-xs text-lamp">you are here</span>
|
|
149
|
+
<span v-else-if="visited.has(r.number)" class="ml-2 text-xs text-muted">visited</span>
|
|
127
150
|
<ul v-if="exitsFrom(r.number).length" class="mt-1 flex flex-col gap-0.5 pl-4">
|
|
128
151
|
<li v-for="(e, i) in exitsFrom(r.number)" :key="i">
|
|
129
152
|
<span class="data inline-block w-24 text-muted">{{ DIRS[e.dir] }}</span>
|
|
@@ -169,7 +169,8 @@ function onKey(e: KeyboardEvent) {
|
|
|
169
169
|
>
|
|
170
170
|
<UIcon v-if="hasChildren(r.n)" :name="open.has(r.n) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="size-4" />
|
|
171
171
|
</span>
|
|
172
|
-
<span class="truncate">{{ name(r.n) }}</span>
|
|
172
|
+
<span class="truncate" :class="live?.changed.objects.has(r.n) ? 'font-semibold text-lamp' : ''">{{ name(r.n) }}</span>
|
|
173
|
+
<span v-if="live?.changed.objects.has(r.n)" class="rounded-full border border-default px-1.5 text-xs text-lamp">changed</span>
|
|
173
174
|
<span class="data ml-auto pl-2 text-xs text-muted">{{ r.n }}</span>
|
|
174
175
|
</li>
|
|
175
176
|
</ul>
|
|
@@ -8,6 +8,8 @@ import { MAX_DEBUG_FILE, type StoryFile, type Symbols } from '@frotzinc/zcode'
|
|
|
8
8
|
*/
|
|
9
9
|
const symbols = shallowRef<Symbols | null>(null)
|
|
10
10
|
const symbolsFile = ref('')
|
|
11
|
+
/** The debug file's text as read (frotzlens hands it to frotztrace with the story). */
|
|
12
|
+
const symbolsText = shallowRef('')
|
|
11
13
|
const symbolsStatus = ref<'none' | 'loading' | 'ready' | 'failed'>('none')
|
|
12
14
|
const symbolsMessage = ref('')
|
|
13
15
|
/** The last file chosen was refused (whatever is loaded stays). */
|
|
@@ -40,6 +42,7 @@ export function useSymbols() {
|
|
|
40
42
|
const match = symbolsMatch(story.bytes, s)
|
|
41
43
|
if (match === false) throw new Error(`${file.name} was written for another story file, so its names would be wrong here.`)
|
|
42
44
|
symbols.value = markRaw(s)
|
|
45
|
+
symbolsText.value = text
|
|
43
46
|
symbolsFile.value = file.name
|
|
44
47
|
symbolsStatus.value = 'ready'
|
|
45
48
|
symbolsVerified.value = match === true
|
|
@@ -57,6 +60,7 @@ export function useSymbols() {
|
|
|
57
60
|
function clearSymbols() {
|
|
58
61
|
generation++
|
|
59
62
|
symbols.value = null
|
|
63
|
+
symbolsText.value = ''
|
|
60
64
|
symbolsFile.value = ''
|
|
61
65
|
symbolsStatus.value = 'none'
|
|
62
66
|
symbolsMessage.value = ''
|
|
@@ -65,5 +69,5 @@ export function useSymbols() {
|
|
|
65
69
|
names.value = true
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
return { symbols, symbolsFile, symbolsStatus, symbolsMessage, symbolsError, symbolsVerified, names, loadSymbols, clearSymbols }
|
|
72
|
+
return { symbols, symbolsFile, symbolsText, symbolsStatus, symbolsMessage, symbolsError, symbolsVerified, names, loadSymbols, clearSymbols }
|
|
69
73
|
}
|
package/app/utils/live.ts
CHANGED
|
@@ -1,19 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A running machine, as a view sees it: where it is, and what changed since it last stopped.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* story file at rest) a view renders exactly as it always has.
|
|
2
|
+
* A running machine, as a view sees it: where it is, and what changed since it last stopped. The
|
|
3
|
+
* Debugger's own type (@frotzinc/zmachine, since frotztrace Phase 3), re-exported here: every view
|
|
4
|
+
* takes it as an optional `live` prop; without one (frotzlens, a story file at rest) a view renders
|
|
5
|
+
* exactly as it always has.
|
|
7
6
|
*/
|
|
8
|
-
export
|
|
9
|
-
/** The program counter: the next instruction to run. */
|
|
10
|
-
pc?: number
|
|
11
|
-
/** Index of the frame being looked at (0 = the outermost). */
|
|
12
|
-
frame?: number
|
|
13
|
-
/** What the machine wrote since the last stop. */
|
|
14
|
-
changed?: {
|
|
15
|
-
addresses: ReadonlySet<number>
|
|
16
|
-
objects: ReadonlySet<number>
|
|
17
|
-
globals: ReadonlySet<number>
|
|
18
|
-
}
|
|
19
|
-
}
|
|
7
|
+
export type { MachineView } from '@frotzinc/zmachine'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frotzinc/zcode-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The Z-machine story views of frotzlens as a Nuxt layer: object tree, globals, map, disassembly, hex, go-to palette and the pieces they share, for frotzlens and frotztrace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Christopher Schweda",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"LICENSE"
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@frotzinc/zcode": "0.
|
|
41
|
+
"@frotzinc/zcode": "0.5.0",
|
|
42
|
+
"@frotzinc/zmachine": "0.2.0"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
45
|
"@nuxt/ui": "^4.12.0",
|