From c0130f01daab8259c9ca39ef7ff073ccc341c609 Mon Sep 17 00:00:00 2001 From: Neil Rackett Date: Fri, 10 Jul 2026 09:16:15 +0100 Subject: [PATCH 1/5] NetConnection: implement Flash Remoting (AMF over HTTP POST) connect() and call() were stubs (somewhatImplemented), so classic AMF remoting apps - NetConnection.connect(gatewayUrl) + call('Service.method', responder, ...args) against Zend_Amf / AMFPHP / FluorineFx-style gateways - could not talk to their backend at all. - connect(): a null command or non-rtmp url is a remoting gateway connection; it succeeds immediately (calls are independent HTTP POSTs) and dispatches NetConnection.Connect.Success asynchronously so listeners registered right after connect() still receive it. rtmp/rtmpt/rtmps remain unimplemented and dispatch Connect.Failed. - call(): serialises arguments with the AVM2 ByteArray AMF codec (AMF3 body behind the 0x11 avmplus marker in an AMF0 envelope), wraps them in a hand-written remoting packet, POSTs application/x-amf via fetch with credentials included, parses the response envelope, decodes bodies back through the same codec, and routes //onResult|onStatus to the matching Responder. Native call arguments arrive flat (no rest collection), so everything after the responder argument is captured via arguments. - Faults: HTTP errors, non-AMF responses (misconfigured gateways answering 200 with an HTML error page) and synchronous failures are routed to the Responder's status callback, falling back to a NetConnection.Call.Failed netStatus event. - Implementation notes: ByteArray must be constructed through its AXClass (its buffer machinery comes from DataBuffer instanceNatives and only exists on the VM-linked prototype); NetStatusEvent likewise, so property access from compiled AS3 sees proper runtime traits; .sec is set on the ByteArray for the AMF codec (same idiom as SharedObject). - Responder: add invokeResult/invokeStatus helpers encapsulating the callback invocation. - Opt-in wire logging via self.__AWAYFL_AMF_DEBUG = true. Verified end-to-end against a Zend_Amf gateway (login flow, list calls, fault paths) with an AS3 app built on a Proxy-based AMF service layer. --- lib/net/NetConnection.ts | 310 ++++++++++++++++++++++++++++++--------- lib/net/Responder.ts | 12 ++ 2 files changed, 255 insertions(+), 67 deletions(-) diff --git a/lib/net/NetConnection.ts b/lib/net/NetConnection.ts index e720e188..f9cd506b 100644 --- a/lib/net/NetConnection.ts +++ b/lib/net/NetConnection.ts @@ -1,5 +1,6 @@ import { EventDispatcher } from '../events/EventDispatcher'; -import { axCoerceString, ASArray, ASObject } from '@awayfl/avm2'; +import { axCoerceString, ASArray, ASObject, Multiname } from '@awayfl/avm2'; +import { NetStatusEvent } from '../events/NetStatusEvent'; import { somewhatImplemented, release, notImplemented } from '@awayfl/swf-loader'; import { Responder } from './Responder'; @@ -46,8 +47,234 @@ export class NetConnection extends EventDispatcher { } call(command: string, responder: Responder /* more args can be provided */): void { - arguments[0] = axCoerceString(command); - this._invoke(2, arguments); + command = axCoerceString(command); + // Native methods receive AS3 arguments flat (no ...rest collection) — everything + // after `responder` is a call argument to be serialised. + const callArgs = Array.prototype.slice.call(arguments, 2); + this._httpCall(command, responder, callArgs); + } + + /* ------------------------------------------------------------------------------------ + * Flash Remoting (AMF over HTTP POST) — the transport used by Zend_Amf / AMFPHP / etc. + * Serialisation is delegated to the AVM2 ByteArray AMF codec so arguments and results + * stay proper AVM2 objects; only the remoting packet envelope is hand-written here. + * ---------------------------------------------------------------------------------- */ + + private static _netStatusEventClass: any = null; + private _httpCallId: number = 0; + private _pendingResponders: Record; + + private _debugAMF(...args: any[]): void { + if (typeof self !== 'undefined' && ( self).__AWAYFL_AMF_DEBUG) + console.log.apply(console, ['[AMF]', ...args]); + } + + private _makeStatusInfo(code: string, level: string, description?: string): ASObject { + const info: any = { code: code, level: level }; + if (description !== undefined) + info.description = description; + return this.sec.createObjectFromJS(info); + } + + private _dispatchStatus(code: string, level: string, description?: string): void { + this._debugAMF('netStatus ->', code, description || ''); + try { + // Events handed to compiled AS3 must be real AVM2 instances (axConstruct on the + // AXClass); a raw `new NetStatusEvent(...)` lacks runtime traits and property + // access from compiled code fails on it. + if (!NetConnection._netStatusEventClass) { + NetConnection._netStatusEventClass = this.sec.application.getClass( + Multiname.FromFQNString('flash.events.NetStatusEvent', 0 /* NamespaceType.Public */)); + } + const event = NetConnection._netStatusEventClass.axConstruct( + [NetStatusEvent.NET_STATUS, false, false, this._makeStatusInfo(code, level, description)]); + event.currentTarget = this; + event.target = this; + this.dispatchEvent(event); + } catch (e) { + console.warn('[NetConnection] netStatus dispatch failed:', e); + } + } + + private static _byteArrayClass: any = null; + + private _newAMFByteArray(): any { + // ByteArray's buffer machinery lives on DataBuffer.prototype and is merged onto the + // VM-linked prototype via instanceNatives — a raw `new ByteArray()` lacks it, so the + // instance must be constructed through the AXClass. + if (!NetConnection._byteArrayClass) { + NetConnection._byteArrayClass = this.sec.application.getClass( + Multiname.FromFQNString('flash.utils.ByteArray', 0 /* NamespaceType.Public */)); + } + const ba: any = NetConnection._byteArrayClass.axConstruct([]); + // the AMF codec resolves classes/objects through the security domain + ba.sec = this.sec; + return ba; + } + + private _httpCall(command: string, responder: Responder, callArgs: any[]): void { + const id = String(++this._httpCallId); + this._debugAMF('call ->', command, 'id', id); + try { + if (!this._pendingResponders) + this._pendingResponders = {}; + if (responder) + this._pendingResponders[id] = responder; + + const ba = this._newAMFByteArray(); + ba._objectEncoding = 3; // AMF3 + ba.writeByte(0x11); // AMF0 avmplus marker: body is AMF3 + ba.writeObject(this.sec.createArray(callArgs)); + const body = new Uint8Array(ba._buffer.slice(0, ba._length)); + const packet = NetConnection._buildRemotingPacket(command, id, body); + + if (!this._uri) { + this._httpFail(id, 'NetConnection is not connected (uri is null)'); + return; + } + fetch(this._uri, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-amf' }, + body: packet + }).then((r) => { + if (!r.ok) + throw new Error('HTTP ' + r.status); + const ct = (r.headers.get('content-type') || '').toLowerCase(); + // A misconfigured gateway can answer 200 with an HTML error page; surface it + // as a fault instead of feeding non-AMF bytes to the parser. + if (ct.indexOf('application/x-amf') === -1) { + return r.text().then((t) => { + throw new Error('Non-AMF response (content-type: ' + (ct || 'none') + '): ' + + t.replace(/\s+/g, ' ').slice(0, 300)); + }); + } + return r.arrayBuffer(); + }).then((buf) => { + this._handleRemotingResponse( buf); + }).catch((err) => { + this._httpFail(id, String((err && err.message) || err)); + }); + } catch (e) { + this._debugAMF('call FAILED synchronously:', e); + this._httpFail(id, String((e && ( e).message) || e)); + } + } + + private _handleRemotingResponse(buf: ArrayBuffer): void { + let messages: { target: string; body: ArrayBuffer }[]; + try { + messages = NetConnection._parseRemotingPacket(buf); + } catch (e) { + console.warn('[NetConnection] AMF response parse failed:', e); + return; + } + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + let value: any; + try { + const ba = this._newAMFByteArray(); + ba.setArrayBuffer(msg.body); + ba._position = 0; + ba._objectEncoding = 0; // AMF0 reader; handles the 0x11 -> AMF3 marker + value = ba.readObject(); + } catch (e) { + console.warn('[NetConnection] AMF body decode failed for', msg.target, e); + continue; + } + const match = /^\/([^\/]*)\/(onResult|onStatus)$/.exec(msg.target); + if (!match) { + console.warn('[NetConnection] unrecognised AMF response target:', msg.target); + continue; + } + const id = match[1], kind = match[2]; + const responder = this._pendingResponders && this._pendingResponders[id]; + if (this._pendingResponders) + delete this._pendingResponders[id]; + this._debugAMF('response', kind, 'id', id, '->', responder ? 'responder' : '(no responder)'); + if (responder) { + try { + kind === 'onResult' ? responder.invokeResult(value) : responder.invokeStatus(value); + } catch (e) { + console.warn('[NetConnection] responder callback threw:', e); + } + } else if (kind === 'onStatus') { + this._dispatchStatus('NetConnection.Call.Failed', 'error'); + } + } + } + + private _httpFail(id: string, description: string): void { + this._debugAMF('call failed id', id, ':', description); + const responder = this._pendingResponders && this._pendingResponders[id]; + if (this._pendingResponders) + delete this._pendingResponders[id]; + if (responder) { + try { + responder.invokeStatus(this._makeStatusInfo('NetConnection.Call.Failed', 'error', description)); + return; + } catch (e) { + console.warn('[NetConnection] responder status callback threw:', e); + } + } + this._dispatchStatus('NetConnection.Call.Failed', 'error', description); + } + + /* AMF remoting packet: u16 version | u16 headerCount | u16 messageCount, then per + * message: utf targetURI | utf responseURI | u32 bodyLength | AMF body. */ + private static _buildRemotingPacket(target: string, responseId: string, body: Uint8Array): Uint8Array { + const enc = new TextEncoder(); + const head: number[] = []; + const u16 = (v: number) => head.push((v >>> 8) & 0xff, v & 0xff); + const u32 = (v: number) => head.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff); + const utf = (str: string) => { + const b = enc.encode(str); + u16(b.length); + for (let i = 0; i < b.length; i++) head.push(b[i]); + }; + u16(3); // version 3 (AMF3-capable) + u16(0); // headers + u16(1); // messages + utf(target); + utf('/' + responseId); + u32(body.length); + const out = new Uint8Array(head.length + body.length); + out.set(head, 0); + out.set(body, head.length); + return out; + } + + private static _parseRemotingPacket(buf: ArrayBuffer): { target: string; body: ArrayBuffer }[] { + const dv = new DataView(buf); + const dec = new TextDecoder(); + let pos = 0; + const u16 = () => { const v = dv.getUint16(pos); pos += 2; return v; }; + const u32 = () => { const v = dv.getUint32(pos); pos += 4; return v; }; + const utf = () => { const n = u16(); const s = dec.decode(new Uint8Array(buf, pos, n)); pos += n; return s; }; + const messages: { target: string; body: ArrayBuffer }[] = []; + u16(); // version + const headers = u16(); + for (let h = 0; h < headers; h++) { + utf(); pos += 1; // name, mustUnderstand + const len = u32(); + if (len !== 0xffffffff) pos += len; + } + const count = u16(); + for (let m = 0; m < count; m++) { + const target = utf(); + utf(); // response uri (unused in replies) + const len = u32(); + let body: ArrayBuffer; + if (len !== 0xffffffff && pos + len <= buf.byteLength) { + body = buf.slice(pos, pos + len); + pos += len; + } else { + body = buf.slice(pos); + pos = buf.byteLength; + } + messages.push({ target: target, body: body }); + } + return messages; } // AS -> JS Bindings @@ -88,70 +315,19 @@ export class NetConnection extends EventDispatcher { } connect(command: string): void { - /* - command = axCoerceString(command); - - release || somewhatImplemented("public flash.net.NetConnection::connect"); - this._uri = command; - if (!command) { - this._connected = true; - this.dispatchEvent(new NetStatusEvent(NetStatusEvent.NET_STATUS, false, false, - this.sec.createObjectFromJS({ level : 'status', code : 'NetConnection.Connect.Success'}))); - } else { - var parsedURL = RtmpJs.parseConnectionString(command); - if (!parsedURL || !parsedURL.host || - (parsedURL.protocol !== 'rtmp' && parsedURL.protocol !== 'rtmpt' && parsedURL.protocol !== 'rtmps')) { - this.dispatchEvent(new NetStatusEvent(NetStatusEvent.NET_STATUS, false, false, - this.sec.createObjectFromJS({ level : 'status', code : 'NetConnection.Connect.Failed'}))); - return; - } - - var service: IRootElementService = this.sec.player; - - var rtmpProps = this.sec.createObjectFromJS({ - app: parsedURL.app, - flashver: Capabilities.version, - swfUrl: service.swfUrl, - tcUrl: command, - fpad: false, - audioCodecs: 0x0FFF, - videoCodecs: 0x00FF, - videoFunction: 1, - pageUrl: service.pageUrl || service.swfUrl, - objectEncoding: 0 - }); - - this._protocol = parsedURL.protocol; - - var secured = parsedURL.protocol === 'rtmps' || - (parsedURL.protocol === 'rtmpt' && (parsedURL.port === 443 || parsedURL.port === 8443)); - this._usingTLS = secured; - var rtmpConnection: RtmpJs.BaseTransport = parsedURL.protocol === 'rtmp' || parsedURL.protocol === 'rtmps' ? - new RtmpJs.Browser.RtmpTransport({ host: parsedURL.host, port: parsedURL.port || 1935, ssl: secured }) : - new RtmpJs.Browser.RtmptTransport({ host: parsedURL.host, port: parsedURL.port || 80, ssl: secured }); - this._rtmpConnection = rtmpConnection; - this._rtmpCreateStreamCallbacks = [null, null]; // reserve first two - - rtmpConnection.onresponse = function (e) { - // - }; - rtmpConnection.onevent = function (e) { - // - }; - rtmpConnection.onconnected = function (e) { - this._connected = true; - this.dispatchEvent(new this.sec.flash.events.NetStatusEvent(events.NetStatusEvent.NET_STATUS, - false, false, - this.sec.createObjectFromJS({ level : 'status', code : 'NetConnection.Connect.Success'}))); - }.bind(this); - rtmpConnection.onstreamcreated = function (e) { - console.log('#streamcreated: ' + e.streamId); - var callback = this._rtmpCreateStreamCallbacks[e.transactionId]; - delete this._rtmpCreateStreamCallbacks[e.transactionId]; - callback(e.stream, e.streamId); - }.bind(this); - rtmpConnection.connect(rtmpProps); - }*/ + command = axCoerceString(command); + this._uri = command; + // A null command or an HTTP(S) url is a Flash Remoting gateway connection: it always + // "succeeds" immediately — calls are independent HTTP POSTs. Dispatch async so + // listeners registered right after connect() still receive the event. + if (!command || !/^rtmpt?s?:/i.test(command)) { + this._connected = true; + Promise.resolve().then(() => this._dispatchStatus('NetConnection.Connect.Success', 'status')); + return; + } + // RTMP / RTMPT / RTMPS streaming transports are not implemented. + console.warn('[NetConnection] rtmp transports are not implemented:', command); + this._dispatchStatus('NetConnection.Connect.Failed', 'error'); } _createRtmpStream(callback) { diff --git a/lib/net/Responder.ts b/lib/net/Responder.ts index 6a0d80b3..627e2ad0 100644 --- a/lib/net/Responder.ts +++ b/lib/net/Responder.ts @@ -42,4 +42,16 @@ export class Responder extends ASObject { this._result = result; this._status = status; } + + /** Internal: invoke the result callback (used by NetConnection HTTP/AMF remoting). */ + invokeResult(value: any): void { + if (this._result) + ( this._result).axApply(null, [value]); + } + + /** Internal: invoke the status/fault callback (used by NetConnection HTTP/AMF remoting). */ + invokeStatus(value: any): void { + if (this._status) + ( this._status).axApply(null, [value]); + } } \ No newline at end of file From d653a2573695b53ba0b0ecdf27bd4a7696e15563 Mon Sep 17 00:00:00 2001 From: Neil Rackett Date: Fri, 10 Jul 2026 09:16:52 +0100 Subject: [PATCH 2/5] EventDispatcherBase: report the aggregation target as currentTarget When an EventDispatcher is constructed with a target argument (the IEventDispatcher aggregation pattern), Flash reports that target - not the internal dispatcher - as event.currentTarget during dispatch. GreenSock (TweenLite/TweenMax v12) dispatches its tween events through an aggregated EventDispatcher, and typical handler code does var tween:TweenMax = event.currentTarget as TweenMax; tween.removeEventListener(event.type, handler); Without this fix the `as` cast returns null and such handlers crash (and, being inside the tween render pass, can take the frame loop's callback chain down with them). target was already reported through _t; do the same for currentTarget. --- lib/events/EventDispatcherBase.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/events/EventDispatcherBase.ts b/lib/events/EventDispatcherBase.ts index b1a3b4f0..70520f54 100644 --- a/lib/events/EventDispatcherBase.ts +++ b/lib/events/EventDispatcherBase.ts @@ -76,6 +76,11 @@ export class EventDispatcherBase extends ASObject { const l: ListenerObject = this._listenerObjects[event.type]; if (l) { + // Flash reports the aggregation target (EventDispatcher(target) constructor arg) + // as currentTarget during dispatch — e.g. GreenSock dispatches tween events via an + // internal aggregated dispatcher and handlers cast event.currentTarget to the tween. + if (this._t) + ( event).currentTarget = this._t; if (!event.target) event.target = this._t; //console.log("dispatchEvent", event.type, (this).adaptee?.id); From ceb8577673a88076aefbd5a12a9186be106aa6f5 Mon Sep 17 00:00:00 2001 From: Neil Rackett Date: Fri, 10 Jul 2026 09:17:16 +0100 Subject: [PATCH 3/5] DisplayObjectContainer: do not throw #2025 when the adaptee is already detached The internal (awayjs) scene graph can diverge from the AS3 display list via orphan/unload management, so removeChild() on a child that is still in the AS3 display list may find the adaptee already detached. Throwing ArgumentError #2025 in that situation erupts through whatever frame callback is currently running - e.g. a tween's onComplete removing a preloader - and can kill the shared ticker, freezing the entire app (rendering continues but no frame events and no input are processed). Warn and treat the child as removed instead. This intentionally deviates from Flash for the divergence case; genuinely removing a never-added child now warns rather than throws, which is the safer failure mode here. --- lib/display/DisplayObjectContainer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/display/DisplayObjectContainer.ts b/lib/display/DisplayObjectContainer.ts index c8504390..b7f6719f 100644 --- a/lib/display/DisplayObjectContainer.ts +++ b/lib/display/DisplayObjectContainer.ts @@ -511,7 +511,11 @@ export class DisplayObjectContainer extends InteractiveObject { try { ( this._adaptee).removeChild(child.adaptee); } catch (e) { - throw ( this.sec).createError('ArgumentError', Errors.NotAChildError); + // The adaptee scene graph can diverge from the AS3 display list (orphan/unload + // management), so a child that is still in the AS3 list may already be detached + // here. Throwing Flash's #2025 in that case erupts through whatever frame + // callback is running (e.g. a tween onComplete) and can kill the shared ticker. + console.warn('[DisplayObjectContainer] removeChild: not a child (already removed?) — ignoring'); } //OrphanManager.addOrphan(child.adaptee); return child; From a06655db50f9dcad8fd0e62ca709041e40ab8885 Mon Sep 17 00:00:00 2001 From: Neil Rackett Date: Fri, 10 Jul 2026 09:17:16 +0100 Subject: [PATCH 4/5] Link flash.ui.MouseCursorData Content that uses custom cursors constructs MouseCursorData before passing it to Mouse.registerCursor(); with the class unlinked, class initialization aborts with 'Class native is not defined: flash.ui.MouseCursorData' - a hard VM error that prevents the SWF's script from running at all, even though registerCursor itself is a harmless no-op here. Link a fresh, working data holder (data/hotSpot/frameRate). The _shumway_flash implementation is not used: it has stale relative imports and notImplemented accessors. --- lib/link.ts | 3 ++- lib/ui/MouseCursorData.ts | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 lib/ui/MouseCursorData.ts diff --git a/lib/link.ts b/lib/link.ts index d4ffa7fc..67d03885 100644 --- a/lib/link.ts +++ b/lib/link.ts @@ -189,6 +189,7 @@ import { GameInputDevice } from './ui/GameInputDevice'; import { GameInputFinger } from './ui/GameInputFinger'; import { GameInputHand } from './ui/GameInputHand'; import { Mouse } from './ui/Mouse'; +import { MouseCursorData } from './ui/MouseCursorData'; import { MultitouchInputMode } from './ui/MultitouchInputMode'; import { Multitouch } from './ui/Multitouch'; import { Timer } from './utils/Timer'; @@ -640,7 +641,7 @@ export function initLink() { //M('flash.ui.KeyLocation', KeyLocation); M('flash.ui.Mouse', Mouse); //M('flash.ui.MouseCursor', MouseCursor); - //M('flash.ui.MouseCursorData', MouseCursorData); + M('flash.ui.MouseCursorData', MouseCursorData); M('flash.ui.Multitouch', Multitouch); M('flash.ui.MultitouchInputMode', MultitouchInputMode); diff --git a/lib/ui/MouseCursorData.ts b/lib/ui/MouseCursorData.ts new file mode 100644 index 00000000..1a343187 --- /dev/null +++ b/lib/ui/MouseCursorData.ts @@ -0,0 +1,44 @@ +import { ASObject } from '@awayfl/avm2'; + +/** + * flash.ui.MouseCursorData — data holder for Mouse.registerCursor(). + * + * Custom native mouse cursors are not rendered by AwayFL (Mouse.registerCursor is a + * no-op), but content that constructs this class must not hit + * "Class native is not defined: flash.ui.MouseCursorData", so a working data holder + * is linked. (A fresh implementation: the _shumway_flash version has stale imports.) + */ +export class MouseCursorData extends ASObject { + + static classInitializer: any = null; + static classSymbols: string[] = null; + static instanceSymbols: string[] = null; + + private _data: any = null; + private _hotSpot: any = null; + private _frameRate: number = 0; + + get data(): any { + return this._data; + } + + set data(value: any) { + this._data = value; + } + + get hotSpot(): any { + return this._hotSpot; + } + + set hotSpot(value: any) { + this._hotSpot = value; + } + + get frameRate(): number { + return this._frameRate; + } + + set frameRate(value: number) { + this._frameRate = +value; + } +} From 6972795653227d937b91f63261fb8f7db7961b3c Mon Sep 17 00:00:00 2001 From: Neil Rackett Date: Fri, 10 Jul 2026 09:17:16 +0100 Subject: [PATCH 5/5] FileReference: satisfy current @types/wicg-file-system-access Recent versions of the typings require file extensions in SaveFilePickerOptions accept maps to be typed as `.${string}`; the plain string no longer compiles. --- lib/net/FileReference.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/net/FileReference.ts b/lib/net/FileReference.ts index 1860e73b..5189b321 100644 --- a/lib/net/FileReference.ts +++ b/lib/net/FileReference.ts @@ -212,12 +212,12 @@ export class FileReference extends EventDispatcher { const mime = knownTypes[ext].mime; types[0] = { description: knownTypes[ext].description, - accept: { [mime] : ext }, + accept: { [mime] : ext as `.${string}` }, }; } else { types[0] = { description: '', - accept: { 'application/unknown' : ext }, + accept: { 'application/unknown' : ext as `.${string}` }, }; }