@colyseus/schema
Version:
Binary state serializer with delta encoding for games
358 lines (296 loc) • 13.8 kB
text/typescript
import type { Schema } from "../Schema.js";
import { TypeContext } from "../types/TypeContext.js";
import { $changes, $encoder, $filter, $getByIndex, $refId } from "../types/symbols.js";
import { encode } from "../encoding/encode.js";
import type { Iterator } from "../encoding/decode.js";
import { OPERATION, SWITCH_TO_STRUCTURE, TYPE_ID } from '../encoding/spec.js';
import { Root } from "./Root.js";
import type { StateView } from "./StateView.js";
import type { ChangeSetName, ChangeTree, ChangeTreeList, ChangeTreeNode } from "./ChangeTree.js";
import { createChangeTreeList } from "./ChangeTree.js";
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
const result = new Uint8Array(a.length + b.length);
result.set(a, 0);
result.set(b, a.length);
return result;
}
export class Encoder<T extends Schema = any> {
static BUFFER_SIZE = 8 * 1024; // 8KB
sharedBuffer: Uint8Array = new Uint8Array(Encoder.BUFFER_SIZE);
context: TypeContext;
state: T;
root: Root;
constructor(state: T) {
//
// Use .cache() here to avoid re-creating a new context for every new room instance.
//
// We may need to make this optional in case of dynamically created
// schemas - which would lead to memory leaks
//
this.context = TypeContext.cache(state.constructor as typeof Schema);
this.root = new Root(this.context);
this.setState(state);
// console.log(">>>>>>>>>>>>>>>> Encoder types");
// this.context.schemas.forEach((id, schema) => {
// console.log("type:", id, schema.name, Object.keys(schema[Symbol.metadata]));
// });
}
protected setState(state: T) {
this.state = state;
this.state[$changes].setRoot(this.root);
}
encode(
it: Iterator = { offset: 0 },
view?: StateView,
buffer: Uint8Array = this.sharedBuffer,
changeSetName: ChangeSetName = "changes",
isEncodeAll = changeSetName === "allChanges",
initialOffset = it.offset // cache current offset in case we need to resize the buffer
): Uint8Array {
const hasView = (view !== undefined);
const rootChangeTree = this.state[$changes];
let current: ChangeTreeList | ChangeTreeNode = this.root[changeSetName];
while (current = current.next) {
const changeTree = (current as ChangeTreeNode).changeTree;
if (hasView) {
if (!view.isChangeTreeVisible(changeTree)) {
// console.log("MARK AS INVISIBLE:", { ref: changeTree.ref.constructor.name, refId: changeTree.ref[$refId], raw: changeTree.ref.toJSON() });
view.invisible.add(changeTree);
continue; // skip this change tree
}
view.invisible.delete(changeTree); // remove from invisible list
}
const changeSet = changeTree[changeSetName];
const ref = changeTree.ref;
// TODO: avoid iterating over change tree if no changes were made
const numChanges = changeSet.operations.length;
if (numChanges === 0) { continue; }
const ctor = ref.constructor;
const encoder = ctor[$encoder];
const filter = ctor[$filter];
const metadata = ctor[Symbol.metadata];
// skip root `refId` if it's the first change tree
// (unless it "hasView", which will need to revisit the root)
if (hasView || it.offset > initialOffset || changeTree !== rootChangeTree) {
buffer[it.offset++] = SWITCH_TO_STRUCTURE & 255;
encode.number(buffer, ref[$refId], it);
}
for (let j = 0; j < numChanges; j++) {
const fieldIndex = changeSet.operations[j];
if (fieldIndex < 0) {
// "pure" operation without fieldIndex (e.g. CLEAR, REVERSE, etc.)
// encode and continue early - no need to reach $filter check
buffer[it.offset++] = Math.abs(fieldIndex) & 255;
continue;
}
const operation = (isEncodeAll)
? OPERATION.ADD
: changeTree.indexedOperations[fieldIndex];
//
// first pass (encodeAll), identify "filtered" operations without encoding them
// they will be encoded per client, based on their view.
//
// TODO: how can we optimize filtering out "encode all" operations?
// TODO: avoid checking if no view tags were defined
//
if (fieldIndex === undefined || operation === undefined || (filter && !filter(ref, fieldIndex, view))) {
// console.log("ADD AS INVISIBLE:", fieldIndex, changeTree.ref.constructor.name)
// view?.invisible.add(changeTree);
continue;
}
encoder(this, buffer, changeTree, fieldIndex, operation, it, isEncodeAll, hasView, metadata);
}
}
if (it.offset > buffer.byteLength) {
// we can assume that n + 1 BUFFER_SIZE will suffice given that we are likely done with encoding at this point
// multiples of BUFFER_SIZE are faster to allocate than arbitrary sizes
const newSize = Math.ceil(it.offset / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
console.warn(`/schema buffer overflow. Encoded state is higher than default BUFFER_SIZE. Use the following to increase default BUFFER_SIZE:
import { Encoder } from "@colyseus/schema";
Encoder.BUFFER_SIZE = ${Math.round(newSize / 1024)} * 1024; // ${Math.round(newSize / 1024)} KB
`);
//
// resize buffer and re-encode (TODO: can we avoid re-encoding here?)
// -> No we probably can't unless we catch the need for resize before encoding which is likely more computationally expensive than resizing on demand
//
const newBuffer = new Uint8Array(newSize);
newBuffer.set(buffer); // copy previous encoding steps beyond the initialOffset
buffer = newBuffer;
// assign resized buffer to local sharedBuffer
if (buffer === this.sharedBuffer) {
this.sharedBuffer = buffer;
}
return this.encode({ offset: initialOffset }, view, buffer, changeSetName, isEncodeAll);
} else {
return buffer.subarray(0, it.offset);
}
}
encodeAll(
it: Iterator = { offset: 0 },
buffer: Uint8Array = this.sharedBuffer
) {
return this.encode(it, undefined, buffer, "allChanges", true);
}
encodeAllView(
view: StateView,
sharedOffset: number,
it: Iterator,
bytes: Uint8Array = this.sharedBuffer
) {
const viewOffset = it.offset;
// try to encode "filtered" changes
this.encode(it, view, bytes, "allFilteredChanges", true, viewOffset);
return concatBytes(
bytes.subarray(0, sharedOffset),
bytes.subarray(viewOffset, it.offset)
);
}
encodeView(
view: StateView,
sharedOffset: number,
it: Iterator,
bytes: Uint8Array = this.sharedBuffer
) {
const viewOffset = it.offset;
//
// Iterate `view.changes` in topological order so a refId is never
// SWITCH_TO_STRUCTURE'd before an earlier op has introduced it on
// the decoder. Map insertion order alone isn't sufficient: a
// sequence like view.remove(child) → view.add(child) on a child
// whose ancestor wasn't yet visible can put the child entry into
// the Map before its newly-visible ancestor.
//
// Hot-path optimization: `view.add` preserves topo order by
// construction (addParentOf walks deepest-ancestor-first before
// touching the obj's own entry). Only `view.remove` can leave the
// Map dirty. `StateView.changesOutOfOrder` tracks this so most
// encodes can iterate `view.changes` directly, paying nothing.
//
const orderedRefIds: Iterable<number> = view.changesOutOfOrder
? this.topoOrderViewChanges(view)
: view.changes.keys();
for (const refId of orderedRefIds) {
const changes = view.changes.get(refId);
const changeTree: ChangeTree = this.root.changeTrees[refId];
if (changeTree === undefined) {
// detached instance, remove from view and skip.
// console.log("detached instance, remove from view and skip.", refId);
view.changes.delete(refId);
continue;
}
const keys = Object.keys(changes);
if (keys.length === 0) {
// FIXME: avoid having empty changes if no changes were made
// console.log("changes.size === 0, skip", refId, changeTree.ref.constructor.name);
continue;
}
const ref = changeTree.ref;
const ctor = ref.constructor;
const encoder = ctor[$encoder];
const metadata = ctor[Symbol.metadata];
bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
encode.number(bytes, ref[$refId], it);
for (let i = 0, numChanges = keys.length; i < numChanges; i++) {
const index = Number(keys[i]);
// workaround when using view.add() on item that has been deleted from state (see test "adding to view item that has been removed from state")
const value = changeTree.ref[$getByIndex](index);
const operation = (value !== undefined && changes[index]) || OPERATION.DELETE;
// isEncodeAll = false
// hasView = true
encoder(this, bytes, changeTree, index, operation, it, false, true, metadata);
}
}
//
// TODO: only clear view changes after all views are encoded
// (to allow re-using StateView's for multiple clients)
//
// clear "view" changes after encoding
view.changes.clear();
view.changesOutOfOrder = false;
// try to encode "filtered" changes
this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
return concatBytes(
bytes.subarray(0, sharedOffset),
bytes.subarray(viewOffset, it.offset)
);
}
/**
* Produce a topological ordering of `view.changes` keys so each refId
* is preceded by any ancestor that's also in the same view's changeset.
*
* The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
* encoded before any earlier op has introduced its refId on the
* decoder, decode fails with "refId not found". An entry's refId can
* only be introduced by an ADD on one of its ancestors — so any
* ancestor that itself appears in this view's pending changes must
* be encoded first.
*
* Implementation: DFS post-order over the parent chain. The `visited`
* Set guards against duplicates; cycles are not expected in a
* well-formed parent chain but the visited check is a cheap safety
* net. Cost is O(n × d) for n entries with parent-chain depth d.
*/
protected topoOrderViewChanges(view: StateView): number[] {
const result: number[] = [];
const visited = new Set<number>();
const visit = (refId: number) => {
if (visited.has(refId)) { return; }
visited.add(refId);
const changeTree = this.root.changeTrees[refId];
if (changeTree !== undefined) {
let chain = changeTree.parentChain;
while (chain) {
const parentRefId = chain.ref[$refId];
if (parentRefId !== undefined && view.changes.has(parentRefId)) {
visit(parentRefId);
}
chain = chain.next;
}
}
result.push(refId);
};
for (const refId of view.changes.keys()) {
visit(refId);
}
return result;
}
discardChanges() {
// discard shared changes
let current = this.root.changes.next;
while (current) {
current.changeTree.endEncode('changes');
current = current.next;
}
this.root.changes = createChangeTreeList();
// discard filtered changes
current = this.root.filteredChanges.next;
while (current) {
current.changeTree.endEncode('filteredChanges');
current = current.next;
}
this.root.filteredChanges = createChangeTreeList();
}
tryEncodeTypeId(
bytes: Uint8Array,
baseType: typeof Schema,
targetType: typeof Schema,
it: Iterator
) {
const baseTypeId = this.context.getTypeId(baseType);
const targetTypeId = this.context.getTypeId(targetType);
if (targetTypeId === undefined) {
console.warn(`/schema WARNING: Class "${targetType.name}" is not registered on TypeRegistry - Please either tag the class with or define a field.`);
return;
}
if (baseTypeId !== targetTypeId) {
bytes[it.offset++] = TYPE_ID & 255;
encode.number(bytes, targetTypeId, it);
}
}
get hasChanges() {
return (
this.root.changes.next !== undefined ||
this.root.filteredChanges.next !== undefined
);
}
}