Migrate Rune Module#765
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the rune module and its corresponding tab to use a plugin-based architecture integrated with @sourceacademy/conductor, enabling communication via serialized channel messages. Key performance feedback focuses on optimizing rendering loops: caching loaded textures directly on the Rune object to prevent redundant image loading and WebGL leaks, pre-deserializing animation frames in SerializedRuneAnimation to avoid high-frequency object creation, and replacing microtask chaining (.then()) in HollusionCanvas with synchronous execution of a resolved ref.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| class SerializedRuneAnimation extends glAnimation { | ||
| constructor(private readonly message: Extract<RuneDisplayMessage, { type: 'animation' }>) { | ||
| super(message.duration, message.fps); | ||
| } | ||
|
|
||
| getFrame(timestamp: number): AnimFrame { | ||
| if (this.message.frames.length === 0) { | ||
| return { | ||
| draw: new NormalRune(Rune.of()).draw | ||
| }; | ||
| } | ||
| return ( | ||
| <WebGLCanvas | ||
| ref={(r) => { | ||
| if (r) { | ||
| rune.draw(r); | ||
| } | ||
| }} | ||
| key={elemKey} | ||
| /> | ||
|
|
||
| const frame = Math.min( | ||
| Math.floor(timestamp * this.message.fps), | ||
| this.message.frames.length - 1 | ||
| ); | ||
| const rune = deserializeRune(this.message.frames[frame]); | ||
| const drawnRune = this.message.mode === 'anaglyph' | ||
| ? new AnaglyphRune(rune) | ||
| : new NormalRune(rune); | ||
|
|
||
| return { | ||
| draw: drawnRune.draw | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
In SerializedRuneAnimation.getFrame, deserializeRune is called on every single animation frame. This creates a new HTMLImageElement 60 times per second for textured runes, causing severe performance degradation and flickering. Deserializing all frames once in the constructor and caching them resolves this bottleneck.
class SerializedRuneAnimation extends glAnimation {
private readonly deserializedFrames: Rune[];
constructor(private readonly message: Extract<RuneDisplayMessage, { type: 'animation' }>) {
super(message.duration, message.fps);
this.deserializedFrames = message.frames.map(deserializeRune);
}
getFrame(timestamp: number): AnimFrame {
if (this.deserializedFrames.length === 0) {
return {
draw: new NormalRune(Rune.of()).draw
};
}
const frame = Math.min(
Math.floor(timestamp * this.message.fps),
this.deserializedFrames.length - 1
);
const rune = this.deserializedFrames[frame];
const drawnRune = this.message.mode === 'anaglyph'
? new AnaglyphRune(rune)
: new NormalRune(rune);
return {
draw: drawnRune.draw
};
}
}
There was a problem hiding this comment.
I don't agree with this, especially if there are 1000+ frames. I think the memory tradeoff isn't worth it
I'm leaving this up to the reviewer to decide
There was a problem hiding this comment.
hmm. In practice, I've never really noticed significant performance degradation having the runes being drawn like this. I think this is okay, but we should probably move all rendering code into the tab eventually. From there if there are optimizations to be made then we can do that.
I also avoided memoizing the rendered runes so that you could have non-deterministic animations. We could decide we don't want that, but I think adding the memoization adds a layer of complexity that challenges how a cadet might expect a function to behave (i.e calling math_random multiple times ought to result in random behaviour)
In other words, if there's no significant impact to performance, I'm also okay to leave it this way,
|
I am wondering if you've seen the migration to |
There was a problem hiding this comment.
I do implore you to add some explanation on what each function is trying to achieve here, and thus what kind of output is expected, or what each interface is intended to be
|
|
||
| function parseFunctionDeclaration(paramTypes: string, returnType: string, sourceName: string): FunctionDeclarationMetadata { | ||
| const declarationSource = `function __sourceAcademyDocs__(${paramTypes}): ${returnType} {}`; | ||
| const sourceFile = ts.createSourceFile(`${sourceName}.functionDeclaration.ts`, declarationSource, ts.ScriptTarget.Latest, true); |
There was a problem hiding this comment.
Do we have to get Typescript to do the parsing? I mean it seems like we know all the details, so we could just populate the metadata object ourselves
There was a problem hiding this comment.
Okay, so I'm looking at Typedoc, and according to a GH issue from a year ago
The support which TypeDoc removed was for parsing real decorators and removing them from the output, not the tag. It predated support for real decorators and was never actually used by any of TypeDoc's code.
So I think I'm gonna migrate to using an @public tag to provide the declaration info
Does that sound good?
There was a problem hiding this comment.
If we want to use a tag, I think we should create our own custom tag to do it.
But what I meant was that it looks as if you're creating a sample program, then getting Typescript to parse the AST data when you could probably skip that step and just go from the paramTypes, returnType and sourceName straight to the output.
There was a problem hiding this comment.
Yep, I've created a custom @publicType and @publicReturnType tag. Could you review the new pipeline?
| loadTab: (tab: string) => void; | ||
| }; | ||
|
|
||
| const RUNE_CONSTANTS = [ |
There was a problem hiding this comment.
I think this value should be defined by the actual rune constants and not just be defined on its own. You can probably get these values using Object.getOwnPropertyNames.
And then you can do some type shennenigans like:
type RuneConstantNames = {
[K in keyof typeof RuneConstants as (typeof RuneConstants)[K] extends () => Rune ? K : never]: K
}
type RuneConstantFunctions = keyof RuneConstantFunctions;There was a problem hiding this comment.
Fair enough, I'll make the change
There was a problem hiding this comment.
I think this value should be defined by the actual rune constants and not just be defined on its own. You can probably get these values using
Object.getOwnPropertyNames.And then you can do some type shennenigans like:
type RuneConstantNames = { [K in keyof typeof RuneConstants as (typeof RuneConstants)[K] extends () => Rune ? K : never]: K } type RuneConstantFunctions = keyof RuneConstantFunctions;
Are the type shenanigans even required? I could just iterate over the RuneFunctions and add the runes to the class. Even if they don't line up with the class declaration, its okay because the class variables are only used for documentation
| func as TypedValue<DataType.CLOSURE, DataType.OPAQUE>, | ||
| [{ type: DataType.NUMBER, value: timestamp }] | ||
| ); | ||
| frames.push(serializeRune(await this.__getRune(result, funcName))); |
There was a problem hiding this comment.
This does kind of have me wondering, would it make sense to just render the entire animation at once with Promise.all?
There was a problem hiding this comment.
I think that would cause a race condition, considering the CSE evaluator can only execute one function at a time.
A later frame could push to the CSE control during CSE execution, which would mess the execution up.
| method.signature = { args, returnType }; | ||
| } | ||
|
|
||
| attachModuleMethod('anaglyph', [DataType.OPAQUE], DataType.OPAQUE); |
There was a problem hiding this comment.
I am always wary of cases where there are multiple sources of truth. In this case, there's some type games we can play to force the attachment call to match the definitions of the actual function:
type TypeToDataType<T> =
T extends any[]
? T['length'] extends 2
? DataType.PAIR | DataType.ARRAY
: DataType.ARRAY
: T extends (...args: any[]) => any
? DataType.CLOSURE
: T extends boolean
? DataType.BOOLEAN
: T extends null
? DataType.EMPTY_LIST
: T extends number
? DataType.NUMBER
: T extends string
? DataType.CONST_STRING
: DataType.OPAQUE;
type TupleToDataTypes<T extends any[]> =
T extends []
? []
: T extends [infer U, ...infer Rest]
? [TypeToDataType<U>, ...TupleToDataTypes<Rest>]
: never;
function attachModuleMethodNew<T extends (...args: any[]) => any>(
f: T,
args: TupleToDataTypes<Parameters<T>>,
returnType: TypeToDataType<ReturnType<T>>
) {
const methodName = f.name;
const method = RuneModulePlugin.prototype[methodName] as SignaturedModuleMethod | undefined;
if (method === undefined) {
throw new Error(`Rune module method "${methodName}" does not exist.`);
}
method.signature = { args, returnType };
}
attachModuleMethodNew(funcs.beside, [DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE);
attachModuleMethodNew(funcs.color, [DataType.OPAQUE, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.OPAQUE);The helper types here probably need more nuance, and should honestly live outside of the rune bundle altogether, but you get the gist.
There was a problem hiding this comment.
I guess the wider question would be if all of this can be avoided, but as an interim measure during this transition period it's okay.
There was a problem hiding this comment.
Yep, I agree that replicating it twice is a pain, but I'm not convinced the tower of types is the best way.
Let me get back to you and see what is possible
There was a problem hiding this comment.
I've moved the attachModuleMethod function to module-lib!
| * When the image finished loading copy it into the texture. | ||
| */ | ||
| const loadTexture = (image: HTMLImageElement): WebGLTexture | null => { | ||
| const loadTexture = async (rune: Rune): Promise<WebGLTexture | null> => { |
There was a problem hiding this comment.
I would rather have it written like this:
let image: HTMLImageElement;
if (typeof imageSource !== 'string') {
image = imageSource;
} else {
image = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = Object.assign(new Image(), {
crossOrigin: 'anonymous',
src: imageSource
});
image.onload = () => {
rune.texture = image;
resolve(image);
};
image.onabort = reject;
image.onerror = reject;
});
}than have an IIFE
There was a problem hiding this comment.
Works! Let me change it
| if (renderFuncRef.current) { | ||
| return renderFuncRef.current(timestamp); | ||
| } | ||
| if (!isLoading.current) { |
There was a problem hiding this comment.
Is the isLoading ref necessary? It seems like we could just share the renderFuncRef. Something like
type RenderFuncState = 'loading' | ((time: number) => unknown)> | null;
const renderFuncRef = React.useRef<RenderFuncState>(null);There was a problem hiding this comment.
Agreed, but I'd rather use { type: ...., value?: ... } the state instead
| return new NormalRune(rune); | ||
| }, [message, rune]); | ||
|
|
||
| if (drawnRune instanceof HollusionRune) { |
There was a problem hiding this comment.
Have you verified that this instanceof check works? Because of the way tabs and bundles are compiled this check might fail at runtime. See here
There was a problem hiding this comment.
It does work. Earlier, the tabs and bundles shared the same object. Now since it's deserialised on the tab side with the tab's HollusionRune class, the instanceof check works
|
Let's avoid memoization until we have evidence that it's necessary. Rules of Optimization: |
Description
This PR migrates the
runemodule to become Conductor-ready. This includes adding serialisation and deserialisation methods and a plugin/channel transport mechanism. It also updates the buildtools documentation engine to support thefunctionDeclarationandvariableDeclarationdecorators for type inference.Type of change
Please delete options that are not relevant.
How Has This Been Tested?
yarn testwith updated testsChecklist: