It should work. Runtime complication was simply an additional complication in my case.
It makes use of the DynamicFunc
class:
http://tads.org/t3doc/doc/sysman/dynfunc.htm
You can feed it Tads source code, compile it and execute it while the game is running. If your initial release of the game contains code that looks for a patch file in the game directory and reads, compiles and executes the code in the file after every startup and after every restore, then you can ship bug fixes in that patch file.
In my case, I’ve put the patch loader in its own patch_loader.t
source file that gets compiled in the game. This isn’t necessary, but due to paranoia I went with a sort of “bootstrap” system where the patcher itself resides in its own runtime-compiled source file (patch_bootstrap.t
) which is shipped with the game. The game itself only compiles and executes patch_bootstrap.t
, which then in turn loads the final patch files. This means you can update the runtime patcher itself in the future.
patch_loader.t
which is compiled with the game itself:
#include <file.h>
#include <strbuf.h>
#include <tadsgen.h>
#include <dynfunc.h>
transient patchObj: object {
bootstrapFunc = nil;
compilePatches = nil;
applyPatches = nil;
bootstrap()
{
try {
local codeFile = File.openTextFile('patch_bootstrap.t', FileAccessRead);
local stringBuf = new StringBuffer(codeFile.getFileSize());
local line = codeFile.readFile();
while (line != nil) {
stringBuf.append(line);
line = codeFile.readFile();
}
codeFile.closeFile();
patchObj.setMethod(&bootstrapFunc, Compiler.compile(toString(stringBuf)));
}
catch (Exception e) {
"\b[Could not bootstrap patcher: <<e.displayException()>>]\b";
}
}
}
// Re-apply all patches after every restore.
postRestorePatcher: PostRestoreObject {
execute()
{
patchObj.applyPatches();
}
}
// Apply all patches on every game init.
initPatcher: InitObject {
execute()
{
patchObj.bootstrap();
patchObj.bootstrapFunc();
patchObj.compilePatches();
patchObj.applyPatches();
}
}
The above looks for patch_bootstrap.t
which is shipped with the game files:
function()
{
patchObj.setMethod(&compilePatches, method()
{
try {
local codeFile = File.openTextFile('patch.t', FileAccessRead, 'utf8');
local stringBuf = new StringBuffer(codeFile.getFileSize());
local line = codeFile.readFile();
while (line != nil) {
stringBuf.append(line);
line = codeFile.readFile();
}
codeFile.closeFile();
patchObj.setMethod(&applyPatches, Compiler.compile(toString(stringBuf)));
}
catch (Exception e) {
"\b[Could not build patches: <<e.displayException()>>]\b";
}
});
}
And that in turn looks for patch.t
which also is shipped with the game files and contains the actual game fixes and updates. Here, I’ll include only few of the fixes to the game as an example.
Pay attention to the warnings in the comments.
/*
* WARNING: Due to a TADS bug, string expressions within "if", "for", "while", and "?:" statements
* can result in nil object reference runtime errors or wrong results. All such comparisons must be
* performed outside of such statements and their result stored in a local variable.
*
* "switch" statements cannot be used. They must be converted to their if-else equivalent.
*/
function()
{
// Update the game's version.
versionInfo.version = '1.4 January 21, 2019';
versionInfo.revision = 4;
// Fixes a statusline bug.
statusLine.initBannerWindow(statuslineBanner);
// Fixes some bugs in me.beforeAction(). We create a new method containing
// the correct code and replace the existing me.beforeAction().
me.setMethod(&beforeAction, method() {
local cmp = gAction.getOrigText().toLower().startsWith('examine');
if (cmp) {
if (me.usedExamine == 14) {
reportAfter('<.p>[Just a reminder that you can use <b>>x</b> as an abbreviation for <b>>examine</b>.
For other useful abbreviations, type <b>>help</b>.]<.p>');
++me.usedExamine;
} else if (me.usedExamine < 14) {
++me.usedExamine;
}
}
// [more fixes here, but omitted in this example]
inherited();
});
// Make HIT KEYBOARD KEYS mean TYPE ON KEYBOARD.
th_keyboard_keys.setMethod(&actionDobjAttack, method() {
local cmp = gAction.getOrigText().toLower().startsWith('hit');
if (cmp) {
replaceAction (TypeOn, th_investor_keyboard);
}
else {
inherited();
}
});
// Fix typo in vocabWords of the tWiring Thing.
tWiring.vocabWords = 'wiring wire wires';
tWiring.initializeVocab();
// Fix typos in room descriptions.
rm_glick.setMethod(&desc, method() {
if (ch_sarah.phase is in (0, 1, 2)) {
"This intimate room is Henry Glick's actual garage as it was at the time of his death. At one end are models of his
famous inventions. At the other end are several odd-looking machines that he was working on before he died. One of these
bears the label `AquaMotor.\' Another sits inside a steel cage and is labeled `Thaumeter.\'
\bPresiding over it all is a friendly looking docent who sits in a rocking chair, happily knitting away from
the basket of yarn next to her. The only exit is <<roomdirsouth>>. ";
}
else if (ch_sarah.phase == 3) {
"You are <<me.posture.participle>> in Henry Glick\'s actual workshop. At one end are models
of his inventions. At the other is a collection of odd-looking machines. One of these
bears the label `AquaMotor.\' Another sits inside a steel cage and is labeled `Thaumeter.\'
\bPresiding over it all is Mrs. Henry Glick, who sits in a rocking chair, happily knitting away from the basket
of yarn next to her, and confidently awaiting the arrival of the investors.
The only exit is <<roomdirsouth>>. ";
} else {
// [...]
}
});
/*
* Verb grammar fixes.
*/
{
local matchObj = TadsObject.createInstanceOf(MenuAction, DynamicProd);
matchObj.grammarTag = 'predicate(v2Menu)';
predicate.deleteAlt('Menu');
predicate.deleteAlt('v2Menu');
predicate.addAlt(''' ('main' | ) 'menu' | 'mainmenu' | 'options' ''', matchObj, cmdDict, t3GetGlobalSymbols());
}
/*
* Hint fixes.
*/
for (local i = firstObj(Goal); i != nil; i = nextObj(i, Goal)) {
local cmp = i.menuContents[1] == 'You have quarter but you still need to fix the machine. ';
if (cmp) {
i.menuContents[4] = 'You can get the spell you need from solving the puzzle in the Quantum Leaps booth. ';
continue;
}
cmp = i.title == 'Where can I find the spell I need for the vending machine? ';
if (cmp) {
i.menuContents[2] = 'You can get the spell you need from solving the puzzle in the Quantum Leaps booth. ';
continue;
}
cmp = i.title == 'How do I solve the envelope puzzle?';
if (cmp) {
i.menuContents[2] = 'Did you notice that the envelope was a little moist when it arrived? ';
continue;
}
cmp = i.title == 'How do I solve the envelope puzzle? '; // note the trailing space
if (cmp) {
i.menuContents[4] = 'Did you notice that the envelope was a little moist when it arrived? ';
continue;
}
}
return 0;
}
Hope that helps. However, I’m not sure this will work reliably with the existing release of the TADS Workbench or the Windows HTML terp. This game was shipped using a terp based on T3VM source code containing updates that don’t exist in the current releases of Workbench or the official terp. We built the game with FrobTADS which has those compiler fixes, and shipped it with the QTads terp which has the T3VM fixes.
I can’t remember anymore if those fixes were about dynamic compilation or not. I guess you’ll have to try it and see
Also, even though it didn’t occur to me at the time, it’s probably best to obfuscate the contents of the patch files (like with a simple rot13 or similar) and de-obfuscate them in the patchObj
loader to avoid spoilers if people decide to read the patch files.
And obviously your game becomes editable this way. People can add new objects and new rooms in the game by simply editing the patch files. It’s hard to do, since object names are not visible, but people can use the TADS reflection system to get object names. Hackable/moddable IF games, anymore?