vimp-engine 0.38.2 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/Cargo.toml +1 -1
- package/package.json +3 -1
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +152 -18
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +47 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +24 -0
- package/src/client/network/WebRtcManager.js +45 -3
- package/src/client/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +151 -9
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +4 -4
- package/src/client/views/includes/reports.pug +1 -1
- package/src/client/voice/JitterBuffer.js +137 -0
- package/src/client/voice/MicCapture.js +307 -0
- package/src/client/voice/PttMachine.js +369 -0
- package/src/client/voice/VoiceClient.js +336 -0
- package/src/client/voice/VoiceCues.js +295 -0
- package/src/client/voice/VoiceEncoder.js +174 -0
- package/src/client/voice/VoicePlayback.js +558 -0
- package/src/client/voice/audioData.js +35 -0
- package/src/client/voice/captureProcessor.worklet.js +136 -0
- package/src/client/voice/opusConfig.js +49 -0
- package/src/client/voice/voiceOutput.js +72 -0
- package/src/client/voice/voiceSupport.js +71 -0
- package/src/client/voice/voiceTones.js +90 -0
- package/src/config/authClient.js +3 -3
- package/src/config/clientServices.js +2 -0
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- package/src/config/voiceDefaults.js +44 -0
- package/src/config/voiceProtocol.js +80 -0
- package/src/config/voteKeys.js +4 -0
- package/src/config/wsports.js +12 -0
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a7-package-repository.js +7 -8
- package/src/devtools/contract/rules/b11-voice-config.js +34 -0
- package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
- package/src/devtools/contract/rules/index.js +5 -1
- package/src/devtools/invariants.js +1 -1
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +93 -15
- package/src/host/PortMachine.js +19 -3
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +15 -2
- package/src/host/meta/SocketManager.js +8 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- package/src/host/meta/modules/voice/Voice.js +430 -0
- package/src/host/meta/modules/voice/channels.js +58 -0
- package/src/host/meta/modules/voice/index.js +1 -0
- package/src/lib/VoiceRelay.js +200 -0
- package/src/lib/applyRoomOverrides.js +1 -1
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +5 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +9 -1
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/packageLink.js +18 -61
- package/src/lib/validators.js +3 -3
- package/src/lib/voiceConfig.js +136 -0
- package/src/lib/voiceMessages.js +172 -0
- package/src/lib/voicePacket.js +142 -0
- package/src/standalone/index.js +1 -1
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VOICE_DENY,
|
|
3
|
+
VOICE_END,
|
|
4
|
+
VOICE_FLOOR_OP,
|
|
5
|
+
VOICE_MAX_TALK_ID,
|
|
6
|
+
VOICE_MSG,
|
|
7
|
+
VOICE_NAME_MAX_LENGTH,
|
|
8
|
+
} from '../config/voiceProtocol.js';
|
|
9
|
+
import { VOICE_RANGES } from '../config/voiceDefaults.js';
|
|
10
|
+
import { isVoiceSpeakerId } from './voicePacket.js';
|
|
11
|
+
|
|
12
|
+
// Единственное место, где описана раскладка кортежей VOICE_DATA и
|
|
13
|
+
// VOICE_FLOOR: хост собирает ими, клиент и хост разбирают. Изоморфный.
|
|
14
|
+
|
|
15
|
+
const COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
16
|
+
const DENY_REASONS = new Set(Object.values(VOICE_DENY));
|
|
17
|
+
const END_REASONS = new Set(Object.values(VOICE_END));
|
|
18
|
+
|
|
19
|
+
const isTalkId = value =>
|
|
20
|
+
Number.isInteger(value) && value >= 1 && value <= VOICE_MAX_TALK_ID;
|
|
21
|
+
|
|
22
|
+
const clampMs = (value, max) => Math.min(max, Math.max(0, Math.round(value)));
|
|
23
|
+
|
|
24
|
+
// хост → клиент (собирает модуль Voice)
|
|
25
|
+
export const grantMessage = (talkId, maxDurationMs) => [
|
|
26
|
+
VOICE_MSG.GRANT,
|
|
27
|
+
talkId,
|
|
28
|
+
maxDurationMs,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const denyMessage = (reason, retryAfterMs) => [
|
|
32
|
+
VOICE_MSG.DENY,
|
|
33
|
+
reason,
|
|
34
|
+
retryAfterMs,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const endMessage = (talkId, reason) => [VOICE_MSG.END, talkId, reason];
|
|
38
|
+
|
|
39
|
+
export const startMessage = ({
|
|
40
|
+
speakerId,
|
|
41
|
+
talkId,
|
|
42
|
+
name,
|
|
43
|
+
teamId,
|
|
44
|
+
chatColor,
|
|
45
|
+
}) => [
|
|
46
|
+
VOICE_MSG.START,
|
|
47
|
+
speakerId,
|
|
48
|
+
talkId,
|
|
49
|
+
name,
|
|
50
|
+
teamId ?? null,
|
|
51
|
+
chatColor ?? null,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export const stopMessage = (speakerId, talkId) => [
|
|
55
|
+
VOICE_MSG.STOP,
|
|
56
|
+
speakerId,
|
|
57
|
+
talkId,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// клиент → хост (собирает VoiceClient)
|
|
61
|
+
export const floorRequestMessage = () => [VOICE_FLOOR_OP.REQUEST];
|
|
62
|
+
|
|
63
|
+
export const floorReleaseMessage = talkId => [VOICE_FLOOR_OP.RELEASE, talkId];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Разбор VOICE_DATA на клиенте. Хост в P2P — браузер другого игрока:
|
|
67
|
+
* принимается только кортеж, прошедший все проверки.
|
|
68
|
+
* @param {*} data
|
|
69
|
+
* @returns {{ type: 'grant', talkId: number, maxDurationMs: number }
|
|
70
|
+
* | { type: 'deny', reason: number, retryAfterMs: number }
|
|
71
|
+
* | { type: 'end', talkId: number, reason: number }
|
|
72
|
+
* | { type: 'start', speakerId: number, talkId: number, name: string,
|
|
73
|
+
* teamId: number|null, color: string|null }
|
|
74
|
+
* | { type: 'stop', speakerId: number, talkId: number }|null}
|
|
75
|
+
*/
|
|
76
|
+
export function parseVoiceMessage(data) {
|
|
77
|
+
if (!Array.isArray(data)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
switch (data[0]) {
|
|
82
|
+
case VOICE_MSG.GRANT: {
|
|
83
|
+
const [, talkId, maxDurationMs] = data;
|
|
84
|
+
|
|
85
|
+
if (!isTalkId(talkId) || !Number.isFinite(maxDurationMs)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
type: 'grant',
|
|
91
|
+
talkId,
|
|
92
|
+
maxDurationMs: clampMs(maxDurationMs, VOICE_RANGES.maxDurationMs[1]),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case VOICE_MSG.DENY: {
|
|
97
|
+
const [, reason, retryAfterMs] = data;
|
|
98
|
+
|
|
99
|
+
if (!DENY_REASONS.has(reason) || !Number.isFinite(retryAfterMs)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
type: 'deny',
|
|
105
|
+
reason,
|
|
106
|
+
retryAfterMs: clampMs(retryAfterMs, VOICE_RANGES.cooldownMs[1]),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case VOICE_MSG.END: {
|
|
111
|
+
const [, talkId, reason] = data;
|
|
112
|
+
|
|
113
|
+
if (!isTalkId(talkId) || !END_REASONS.has(reason)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { type: 'end', talkId, reason };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case VOICE_MSG.START: {
|
|
121
|
+
const [, speakerId, talkId, name, teamId, color] = data;
|
|
122
|
+
|
|
123
|
+
if (!isVoiceSpeakerId(speakerId) || !isTalkId(talkId)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
type: 'start',
|
|
129
|
+
speakerId,
|
|
130
|
+
talkId,
|
|
131
|
+
name:
|
|
132
|
+
typeof name === 'string' ? name.slice(0, VOICE_NAME_MAX_LENGTH) : '',
|
|
133
|
+
teamId: Number.isInteger(teamId) ? teamId : null,
|
|
134
|
+
color: typeof color === 'string' && COLOR_RE.test(color) ? color : null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case VOICE_MSG.STOP: {
|
|
139
|
+
const [, speakerId, talkId] = data;
|
|
140
|
+
|
|
141
|
+
if (!isVoiceSpeakerId(speakerId) || !isTalkId(talkId)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { type: 'stop', speakerId, talkId };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Разбор VOICE_FLOOR на хосте.
|
|
155
|
+
* @param {*} data
|
|
156
|
+
* @returns {{ op: 'request' } | { op: 'release', talkId: number }|null}
|
|
157
|
+
*/
|
|
158
|
+
export function parseFloorMessage(data) {
|
|
159
|
+
if (!Array.isArray(data)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (data[0] === VOICE_FLOOR_OP.REQUEST) {
|
|
164
|
+
return { op: 'request' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (data[0] === VOICE_FLOOR_OP.RELEASE && isTalkId(data[1])) {
|
|
168
|
+
return { op: 'release', talkId: data[1] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import wsports from '../config/wsports.js';
|
|
2
|
+
import {
|
|
3
|
+
VOICE_HEADER_BYTES,
|
|
4
|
+
VOICE_MAX_PACKET_BYTES,
|
|
5
|
+
VOICE_MAX_SPEAKER_ID,
|
|
6
|
+
VOICE_PACKET_VERSION,
|
|
7
|
+
} from '../config/voiceProtocol.js';
|
|
8
|
+
|
|
9
|
+
// Кодек голосового пакета (раскладка — config/voiceProtocol.js). Изоморфный:
|
|
10
|
+
// им пользуются клиент и ретранслятор вкладки хоста (dedicated голоса не имеет).
|
|
11
|
+
|
|
12
|
+
const isByte = value => Number.isInteger(value) && value >= 0 && value <= 0xff;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* id говорящего на проводе: целое 0..VOICE_MAX_SPEAKER_ID.
|
|
16
|
+
* @param {*} value
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function isVoiceSpeakerId(value) {
|
|
20
|
+
return Number.isInteger(value) && value >= 0 && value <= VOICE_MAX_SPEAKER_ID;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Нормализует вход к Uint8Array без копии: ArrayBuffer, любой
|
|
25
|
+
* ArrayBufferView (в т.ч. Node Buffer — он и есть Uint8Array).
|
|
26
|
+
* @param {*} data
|
|
27
|
+
* @returns {Uint8Array|null} null — не бинарные данные.
|
|
28
|
+
*/
|
|
29
|
+
export function toBytes(data) {
|
|
30
|
+
if (data instanceof Uint8Array) {
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (data instanceof ArrayBuffer) {
|
|
35
|
+
return new Uint8Array(data);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (ArrayBuffer.isView(data)) {
|
|
39
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Собирает пакет: новый Uint8Array(VOICE_HEADER_BYTES + payload.length).
|
|
47
|
+
* speakerId — 0 у клиента. seq обрезается до u16.
|
|
48
|
+
* @param {Object} packet
|
|
49
|
+
* @param {number} packet.port - Байт 0 (wsports.client/server.VOICE_AUDIO).
|
|
50
|
+
* @param {number} [packet.speakerId]
|
|
51
|
+
* @param {number} packet.talkId
|
|
52
|
+
* @param {number} packet.seq
|
|
53
|
+
* @param {Uint8Array} packet.payload - Opus-пакет.
|
|
54
|
+
* @returns {Uint8Array}
|
|
55
|
+
* @throws {RangeError} port/speakerId/talkId не целые 0..255.
|
|
56
|
+
*/
|
|
57
|
+
export function encodeVoicePacket({
|
|
58
|
+
port,
|
|
59
|
+
speakerId = 0,
|
|
60
|
+
talkId,
|
|
61
|
+
seq,
|
|
62
|
+
payload,
|
|
63
|
+
}) {
|
|
64
|
+
if (!isByte(port) || !isByte(speakerId) || !isByte(talkId)) {
|
|
65
|
+
throw new RangeError('voice packet: port, speakerId, talkId must be u8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const bytes = new Uint8Array(VOICE_HEADER_BYTES + payload.length);
|
|
69
|
+
|
|
70
|
+
bytes[0] = port;
|
|
71
|
+
bytes[1] = VOICE_PACKET_VERSION;
|
|
72
|
+
bytes[2] = speakerId;
|
|
73
|
+
bytes[3] = talkId;
|
|
74
|
+
|
|
75
|
+
new DataView(bytes.buffer).setUint16(4, seq & 0xffff);
|
|
76
|
+
bytes.set(payload, VOICE_HEADER_BYTES);
|
|
77
|
+
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Читает и проверяет заголовок: длина в [VOICE_HEADER_BYTES + 1,
|
|
83
|
+
* VOICE_MAX_PACKET_BYTES], версия === VOICE_PACKET_VERSION.
|
|
84
|
+
* @param {Uint8Array} bytes
|
|
85
|
+
* @returns {{ port: number, version: number, speakerId: number,
|
|
86
|
+
* talkId: number, seq: number }|null}
|
|
87
|
+
*/
|
|
88
|
+
export function readVoiceHeader(bytes) {
|
|
89
|
+
if (
|
|
90
|
+
!bytes ||
|
|
91
|
+
bytes.length <= VOICE_HEADER_BYTES ||
|
|
92
|
+
bytes.length > VOICE_MAX_PACKET_BYTES ||
|
|
93
|
+
bytes[1] !== VOICE_PACKET_VERSION
|
|
94
|
+
) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
port: bytes[0],
|
|
100
|
+
version: bytes[1],
|
|
101
|
+
speakerId: bytes[2],
|
|
102
|
+
talkId: bytes[3],
|
|
103
|
+
seq: new DataView(bytes.buffer, bytes.byteOffset).getUint16(4),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Готовит пакет клиента к раздаче: байт 0 ← server.VOICE_AUDIO, байт 2 ←
|
|
109
|
+
* speakerId. Правит буфер на месте (ретранслятор владеет им).
|
|
110
|
+
* @param {Uint8Array} bytes
|
|
111
|
+
* @param {number} speakerId - 0..VOICE_MAX_SPEAKER_ID.
|
|
112
|
+
* @returns {Uint8Array}
|
|
113
|
+
*/
|
|
114
|
+
export function stampForListeners(bytes, speakerId) {
|
|
115
|
+
if (!isVoiceSpeakerId(speakerId)) {
|
|
116
|
+
throw new RangeError('voice packet: speakerId out of range');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
bytes[0] = wsports.server.VOICE_AUDIO;
|
|
120
|
+
bytes[2] = speakerId;
|
|
121
|
+
|
|
122
|
+
return bytes;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Полезная нагрузка (Opus) без копии.
|
|
127
|
+
* @param {Uint8Array} bytes
|
|
128
|
+
* @returns {Uint8Array}
|
|
129
|
+
*/
|
|
130
|
+
export function voicePayload(bytes) {
|
|
131
|
+
return bytes.subarray(VOICE_HEADER_BYTES);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Знаковая разность номеров u16 с переполнением: seqDiff(1, 65535) === 2.
|
|
136
|
+
* @param {number} a
|
|
137
|
+
* @param {number} b
|
|
138
|
+
* @returns {number}
|
|
139
|
+
*/
|
|
140
|
+
export function seqDiff(a, b) {
|
|
141
|
+
return ((a - b + 32768) & 0xffff) - 32768;
|
|
142
|
+
}
|
package/src/standalone/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { ensureGameShell } from '../client/views/gameShell.js';
|
|
|
10
10
|
// репозитория игры — `npm run dev` открывает вкладку с работающим матчем без
|
|
11
11
|
// мастера, без входа и без экрана лобби.
|
|
12
12
|
//
|
|
13
|
-
// Отличия от прода (
|
|
13
|
+
// Отличия от прода (осознанные):
|
|
14
14
|
// - хост крутится в главном потоке (inline), а не в Worker'е: HostPlugin
|
|
15
15
|
// непередаваем постмесседжем, а SDK получает живой объект плагина;
|
|
16
16
|
// - личность гостевая (ник из формы или из playerName), рейтинг и
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# gen-api3 — ЗАМОРОЖЕНО
|
|
2
2
|
|
|
3
3
|
Снимок плагина 2026-08-29, поколение `engineApi: 3`.
|
|
4
|
-
Не править: правка фикстуры маскирует слом
|
|
5
|
-
|
|
4
|
+
Не править: правка фикстуры маскирует слом совместимости.
|
|
5
|
+
Новое поколение
|
|
6
6
|
добавляется копированием текущего `../../miniGame`, а не правкой
|
|
7
7
|
этого каталога.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# gen-api4 — ЗАМОРОЖЕНО
|
|
2
2
|
|
|
3
3
|
Снимок плагина 2026-08-29, поколение `engineApi: 4`.
|
|
4
|
-
Не править: правка фикстуры маскирует слом
|
|
5
|
-
|
|
4
|
+
Не править: правка фикстуры маскирует слом совместимости.
|
|
5
|
+
Новое поколение
|
|
6
6
|
добавляется копированием текущего `../../miniGame`, а не правкой
|
|
7
7
|
этого каталога.
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
// Разбор `core/src/abi.rs` — единственный раздел слепка плагинной
|
|
2
|
-
// поверхности, который нельзя собрать импортом: методы wasm-ABI живут внутри
|
|
3
|
-
// `macro_rules!`, раскрывающихся уже в крейте игры. Слепок читает файл как
|
|
4
|
-
// текст и вытаскивает имена и нормализованные сигнатуры `pub fn`.
|
|
5
|
-
//
|
|
6
|
-
// Разбор обязан ПАДАТЬ, а не возвращать пустоту: молчаливо пустой раздел
|
|
7
|
-
// слепка пропустит любое нарушение И1/И3 (plan/plugin-forward-compat).
|
|
8
|
-
|
|
9
|
-
// имя макроса → раздел слепка (`abi.game` / `abi.client`)
|
|
10
|
-
const MACRO_SECTIONS = {
|
|
11
|
-
// имена макросов Rust — строками: camelCase к ним неприменим
|
|
12
|
-
'export_game_core_abi': 'game',
|
|
13
|
-
'export_client_core_abi': 'client',
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @param {string} source - Содержимое `core/src/abi.rs`.
|
|
18
|
-
* @returns {{game: Object[], client: Object[]}} Методы ABI по разделам,
|
|
19
|
-
* каждый — { name, args, ret }, отсортированные по имени.
|
|
20
|
-
*/
|
|
21
|
-
export function parseAbi(source) {
|
|
22
|
-
const blocks = splitMacros(source);
|
|
23
|
-
|
|
24
|
-
if (blocks.length === 0) {
|
|
25
|
-
throw new Error(
|
|
26
|
-
'abiParse: no macro_rules! block found in core/src/abi.rs — the file ' +
|
|
27
|
-
'was restructured; fix the parser instead of letting the surface ' +
|
|
28
|
-
'snapshot go silently empty',
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const sections = {};
|
|
33
|
-
|
|
34
|
-
for (const block of blocks) {
|
|
35
|
-
const section = MACRO_SECTIONS[block.macro];
|
|
36
|
-
|
|
37
|
-
if (!section) {
|
|
38
|
-
continue; // макрос вне ABI плагина — не часть поверхности
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const methods = parseMethods(block.body);
|
|
42
|
-
|
|
43
|
-
if (methods.length === 0) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
`abiParse: macro_rules! ${block.macro} yielded no "pub fn" — the ` +
|
|
46
|
-
'file was restructured; fix the parser',
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
sections[section] = methods.sort((a, b) => a.name.localeCompare(b.name));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
for (const [macro, section] of Object.entries(MACRO_SECTIONS)) {
|
|
54
|
-
if (!sections[section]) {
|
|
55
|
-
throw new Error(
|
|
56
|
-
`abiParse: macro_rules! ${macro} not found in core/src/abi.rs`,
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return sections;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// тело каждого `macro_rules! <name> { … }` — от имени до парной скобке
|
|
65
|
-
function splitMacros(source) {
|
|
66
|
-
const re = /macro_rules!\s+([a-z_0-9]+)\s*\{/g;
|
|
67
|
-
const blocks = [];
|
|
68
|
-
let match;
|
|
69
|
-
|
|
70
|
-
while ((match = re.exec(source)) !== null) {
|
|
71
|
-
const open = re.lastIndex - 1;
|
|
72
|
-
const close = matchBrace(source, open, '{', '}');
|
|
73
|
-
|
|
74
|
-
blocks.push({
|
|
75
|
-
macro: match[1],
|
|
76
|
-
body: source.slice(open + 1, close),
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return blocks;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// `pub fn name(args) -> ret` внутри тела макроса; тело метода пропускается
|
|
84
|
-
function parseMethods(body) {
|
|
85
|
-
const re = /pub\s+fn\s+([a-z_0-9]+)\s*\(/g;
|
|
86
|
-
const methods = [];
|
|
87
|
-
let match;
|
|
88
|
-
|
|
89
|
-
while ((match = re.exec(body)) !== null) {
|
|
90
|
-
const open = re.lastIndex - 1;
|
|
91
|
-
const close = matchBrace(body, open, '(', ')');
|
|
92
|
-
const tail = body.slice(close + 1);
|
|
93
|
-
const arrow = /^\s*->([^{;]+)/.exec(tail);
|
|
94
|
-
|
|
95
|
-
methods.push({
|
|
96
|
-
name: match[1],
|
|
97
|
-
args: splitArgs(body.slice(open + 1, close)).map(normalizeType),
|
|
98
|
-
ret: arrow ? normalizeType(arrow[1]) : '()',
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return methods;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// индекс скобки, парной открывающей на позиции `open`
|
|
106
|
-
function matchBrace(source, open, openChar, closeChar) {
|
|
107
|
-
let depth = 0;
|
|
108
|
-
|
|
109
|
-
for (let i = open; i < source.length; i += 1) {
|
|
110
|
-
if (source[i] === openChar) {
|
|
111
|
-
depth += 1;
|
|
112
|
-
} else if (source[i] === closeChar) {
|
|
113
|
-
depth -= 1;
|
|
114
|
-
|
|
115
|
-
if (depth === 0) {
|
|
116
|
-
return i;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
throw new Error(`abiParse: unbalanced ${openChar} at offset ${open}`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// список аргументов по запятым верхнего уровня; получатель (`&self`,
|
|
125
|
-
// `&mut self`) в сигнатуру не входит — он не часть бинарного контракта
|
|
126
|
-
function splitArgs(list) {
|
|
127
|
-
const args = [];
|
|
128
|
-
let depth = 0;
|
|
129
|
-
let current = '';
|
|
130
|
-
|
|
131
|
-
for (const ch of list) {
|
|
132
|
-
if (ch === '<' || ch === '(' || ch === '[') {
|
|
133
|
-
depth += 1;
|
|
134
|
-
} else if (ch === '>' || ch === ')' || ch === ']') {
|
|
135
|
-
depth -= 1;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (ch === ',' && depth === 0) {
|
|
139
|
-
args.push(current);
|
|
140
|
-
current = '';
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
current += ch;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
args.push(current);
|
|
148
|
-
|
|
149
|
-
return args
|
|
150
|
-
.map(arg => arg.trim())
|
|
151
|
-
.filter(arg => arg.length > 0 && !/^&?\s*(mut\s+)?self$/.test(arg))
|
|
152
|
-
.map(arg => {
|
|
153
|
-
const colon = arg.indexOf(':');
|
|
154
|
-
|
|
155
|
-
// аргумент без имени в этих макросах не встречается: имя есть всегда
|
|
156
|
-
return colon === -1 ? arg : arg.slice(colon + 1);
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Стабильная строка типа: меняется тогда и только тогда, когда меняется
|
|
161
|
-
// бинарный контракт. Ссылка/`mut`/пробелы/путь до wasm-bindgen на него не
|
|
162
|
-
// влияют; `Result<T, JsError>` сворачивается в `Result<T>` — вариант ошибки
|
|
163
|
-
// у всех методов один.
|
|
164
|
-
function normalizeType(type) {
|
|
165
|
-
const flat = type
|
|
166
|
-
.replaceAll('::wasm_bindgen::', '')
|
|
167
|
-
.replaceAll('wasm_bindgen::', '')
|
|
168
|
-
.replace(/\bmut\b/g, '')
|
|
169
|
-
.replaceAll('&', '')
|
|
170
|
-
.replace(/\s+/g, '');
|
|
171
|
-
|
|
172
|
-
return flat.replace(/^Result<(.*),JsError>$/, 'Result<$1>');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export default parseAbi;
|