In Zork I, does THIEF-ENGROSSED actually work?

The reason I’m asking about this is that I’m working on a tool-assisted speedrun of the Apple II version of Zork I. If THIEF-ENGROSSED worked, it would enable a 2-turn kill on the thief in the early game. Without THIEF-ENGROSSED, it requires 4 turns. (Fighting with the knife, rather than the sword, would reduce both of these counts by 1, but having to get the knife from the attic would cost more time than it saves.)

The BizHawk emulator supports Lua scripting, so we can write a script to report whenever THIEF-ENGROSSED changes. (The lookup is a little tricky because THIEF-ENGROSSED is relative to the GLOBAL table, whose address is stored at runtime.)

thief-engrossed.lua
-- GLOBAL is a 16-bit pointer to the table of 16-bit global variables.
-- https://github.com/erkyrath/infocom-zcode-terps/blob/d5ac95a838/apple/zip/eq.l#L76
local GLOBAL = 0xac
-- THIEF-ENGROSSED is at index 30 in the table.
local THIEF_ENGROSSED = 30

-- Whenever the 16-bit value at addr is written to, schedule the given callback
-- to be called, once, at the end of the frame, with addr as its argument. At
-- most one such callback will occur, even if the value is written to more than
-- once. This is because, on the Virtu core, the on_bus_write callback doesn't
-- tell us what the value to be written is. Waiting until end of frame gives
-- the value a chance to be committed to memory. Returns the on_bus_write event
-- ids for the first and second byte of the 16-bit value.
local function on_write_16_once(callback, addr)
	local onframeend_id = nil
	local function written()
		if onframeend_id ~= nil then
			-- The end-of-frame event is already scheduled.
			return
		end
		onframeend_id = event.onframeend(function ()
			assert(event.unregisterbyid(onframeend_id))
			onframeend_id = nil
			callback(addr)
		end)
	end
	return {
		event.on_bus_write(written, addr + 0),
		event.on_bus_write(written, addr + 1),
	}
end

local prev_thief_engrossed = nil
local function thief_engrossed_written(addr)
	local thief_engrossed = memory.read_u16_be(addr)
	if thief_engrossed ~= prev_thief_engrossed then
		prev_thief_engrossed = thief_engrossed
		print(string.format("frame %d\tTHIEF-ENGROSSED=%d",
			emu.framecount(), thief_engrossed))
	end
end

local thief_engrossed_write_id = nil
-- Unregister any existing THIEF-ENGROSSED write event handlers, and register
-- new THIEF-ENGROSSED write handlers if GLOBAL is nonzero.
local function update_thief_engrossed_write_event()
	if thief_engrossed_write_id ~= nil then
		assert(event.unregisterbyid(thief_engrossed_write_id[1]))
		assert(event.unregisterbyid(thief_engrossed_write_id[2]))
		thief_engrossed_write_id = nil
	end
	local global = memory.read_u16_le(GLOBAL)
	if global ~= 0 then
		local thief_engrossed_addr = global + THIEF_ENGROSSED*2
		thief_engrossed_write_id = on_write_16_once(thief_engrossed_written, thief_engrossed_addr)
	end
end

on_write_16_once(update_thief_engrossed_write_event, GLOBAL)
update_thief_engrossed_write_event()

Run the script on a prerecorded input file and Apple II disk image like so:

./EmuHawkMono.sh --movie=100%-80-brief.bk2 --lua=thief-engrossed.lua Zork_I_r88.dsk

This is the output of the script. Frame 285 is the interpreter initializing itself. Frames 23114 and 23133 are at the beginning of the thief battle.

frame 285	THIEF-ENGROSSED=0
frame 23114	THIEF-ENGROSSED=1
frame 23133	THIEF-ENGROSSED=0

Combining the above frame timestamps with a timestamped transcript of the run, we see that THIEF-ENGROSSED indeed gets set upon giving the egg to the thief, but becomes unset again before the player’s next turn.

23013	>giVe eGG
23070	(to the thief)
23114 	[THIEF-ENGROSSED=1]
23114	The thief is taken aback by your unexpected generosity, but accepts the
23118	jewel-encrusted egg and stops to admire its beauty.
23133 	[THIEF-ENGROSSED=0]
23144	
23147	>hIT MAN
23183	(with the sword)

(The funny capitalization of commands is RNG manipulation to get favorable combat results.)

4 Likes