/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */ // @ts-nocheck export function createHistoryController(scope) { const documentScope = scope.documentScope || scope; const renderScope = scope.renderScope || scope; const historyScope = scope.historyScope || scope; const uiScope = scope.uiScope || scope; const sessionScope = scope.sessionScope || scope; const MAX_HISTORY_ENTRIES = 40; const MAX_PERSISTED_HISTORY_CHARS = 1_500_000; const OPERATION_CHECKPOINT_INTERVAL = 12; let pendingPersistTimer = 0; function cloneValue(value) { if (typeof structuredClone === "function") { return structuredClone(value); } return value == null ? value : JSON.parse(JSON.stringify(value)); } function clearPendingPersistTimer() { if (!pendingPersistTimer) { return; } window.clearTimeout(pendingPersistTimer); pendingPersistTimer = 0; } function persistHistoryState() { clearPendingPersistTimer(); try { const savedIndex = Math.max(0, Math.min( scope.historyEntries.findIndex((entry) => Number(entry?.id) === Number(historyScope.lastSavedHistoryId)), scope.historyEntries.length - 1, )); const savedState = scope.historyEntries.length > 0 ? captureHistoryStateAtIndex(savedIndex >= 0 ? savedIndex : scope.historyIndex) : captureState(); const payload = { mapId: String(documentScope.mapId || scope.mapId || ""), savedStateSignature: getStateSignature(savedState), historyEntries: historyScope.historyEntries, historyIndex: historyScope.historyIndex, historySelectionIndex: historyScope.historySelectionIndex, nextHistoryId: historyScope.nextHistoryId, lastSavedHistoryId: historyScope.lastSavedHistoryId, }; const serialized = JSON.stringify(payload); if (serialized.length > MAX_PERSISTED_HISTORY_CHARS) { window.localStorage.removeItem(historyScope.historyStorageKey); return false; } window.localStorage.setItem(historyScope.historyStorageKey, serialized); return true; } catch {} return false; } function schedulePersistHistoryState() { clearPendingPersistTimer(); pendingPersistTimer = window.setTimeout(() => { pendingPersistTimer = 0; persistHistoryState(); }, 120); return true; } function restoreHistoryState() { try { const raw = window.localStorage.getItem(historyScope.historyStorageKey); if (!raw) { return null; } const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { return null; } return parsed; } catch { return null; } } function applyHistorySnapshot(snapshot) { if (!snapshot || typeof snapshot !== "object") { return false; } const snapshotMapId = String(snapshot.mapId || "").trim(); const currentMapId = String(documentScope.mapId || scope.mapId || "").trim(); if (snapshotMapId && currentMapId && snapshotMapId !== currentMapId) { return false; } const entries = Array.isArray(snapshot.historyEntries) ? snapshot.historyEntries : null; if (!entries || entries.length === 0) { return false; } const savedId = Number(snapshot.lastSavedHistoryId) || 0; const currentId = Number(snapshot.historyEntries?.[Number(snapshot.historyIndex) || 0]?.id) || 0; if (!savedId || !currentId || savedId !== currentId) { return false; } const savedStateSignature = String(snapshot.savedStateSignature || "").trim(); if (savedStateSignature) { const currentLoadedSignature = getStateSignature(captureState()); if (savedStateSignature !== currentLoadedSignature) { return false; } } historyScope.historyEntries = entries; historyScope.historyIndex = Math.max(0, Math.min(Number(snapshot.historyIndex) || 0, historyScope.historyEntries.length - 1)); historyScope.historySelectionIndex = Math.max(0, Math.min(Number(snapshot.historySelectionIndex) || historyScope.historyIndex, historyScope.historyEntries.length - 1)); historyScope.nextHistoryId = Math.max(1, Number(snapshot.nextHistoryId) || (historyScope.historyEntries[historyScope.historyEntries.length - 1]?.seq || 0) + 1); historyScope.lastSavedHistoryId = Math.max(1, Number(snapshot.lastSavedHistoryId) || historyScope.historyEntries[historyScope.historyIndex]?.id || 1); if (!restoreToHistoryIndex(historyScope.historyIndex)) { const currentState = historyScope.historyEntries[historyScope.historyIndex] && historyScope.historyEntries[historyScope.historyIndex].state ? historyScope.historyEntries[historyScope.historyIndex].state : null; if (currentState) { applyState(currentState); } } return true; } function captureState() { return { width: Number(documentScope.width) || 1, height: Number(documentScope.height) || 1, mapName: String(documentScope.mapName || scope.mapId || ""), backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor), backgroundTileId: String(documentScope.backgroundTileId || "").trim(), heightBlurStep: Math.max(0, Math.min(1, Number(documentScope.heightBlurStep ?? documentScope.heightDetailStep) || 0.1)), layers: documentScope.cloneLayers(documentScope.roomLayers), heightLayers: documentScope.cloneHeightLayers(documentScope.heightLayers), npcs: documentScope.cloneNpcOverlays(documentScope.npcOverlays), worldChunkBackgrounds: typeof scope.captureWorldChunkBackgroundState === "function" ? scope.captureWorldChunkBackgroundState() : {}, worldBookmarks: typeof scope.captureWorldBookmarkState === "function" ? scope.captureWorldBookmarkState() : [], editorUi: documentScope.cloneEditorUiState(), }; } function refreshUiAfterHistoryMutation() { documentScope.ensureBaseLayer(); sessionScope.activeLayer = documentScope.roomLayers.some((layer) => layer.layer === sessionScope.activeLayer) ? sessionScope.activeLayer : 0; if (!documentScope.npcOverlays.some((npc) => npc.id === sessionScope.selectedNpcId)) { sessionScope.selectedNpcId = documentScope.npcOverlays[0] ? String(documentScope.npcOverlays[0].id || "") : ""; } if (uiScope.refreshInstanceSectionState) { uiScope.refreshInstanceSectionState(); } uiScope.renderPaintPalette(); if (uiScope.renderHeightLayerList) { uiScope.renderHeightLayerList(); } uiScope.renderInstancePalette(); uiScope.renderLayerList(); uiScope.renderNpcList(); if (uiScope.renderTriggerList) { uiScope.renderTriggerList(); } if (uiScope.renderMonsterList) { uiScope.renderMonsterList(); } if (uiScope.renderPathList) { uiScope.renderPathList(); } if (uiScope.renderTransitionList) { uiScope.renderTransitionList(); } uiScope.refreshInformationPanel(); if (typeof scope.refreshWorldOverviewWindow === "function") { scope.refreshWorldOverviewWindow(); } renderScope.draw(); } function applyState(state, options) { const config = options && typeof options === "object" ? options : {}; documentScope.width = Math.max(1, Number(state?.width) || documentScope.width || 1); documentScope.height = Math.max(1, Number(state?.height) || documentScope.height || 1); documentScope.mapName = String(state?.mapName || scope.mapId || documentScope.mapName || ""); documentScope.backgroundColor = documentScope.normalizeMapBackgroundColor(state?.backgroundColor || documentScope.backgroundColor); documentScope.backgroundTileId = documentScope.normalizeBackgroundTileId(state?.backgroundTileId); documentScope.heightBlurStep = Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || documentScope.heightBlurStep || documentScope.heightDetailStep || 0.1)); documentScope.roomLayers = documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []); documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []); const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []); sessionScope.editorUiState = state && state.editorUi ? documentScope.cloneEditorUiState(state.editorUi) : { panelLayouts: {} }; if (!documentScope.getHeightLayerById(sessionScope.activeHeightLayerId)) { sessionScope.activeHeightLayerId = String(documentScope.heightLayers[0]?.id || "").trim(); } if (sessionScope.editingTargetKind === "height" && !sessionScope.activeHeightLayerId) { sessionScope.editingTargetKind = "room"; } nextNpcs.forEach((npc) => documentScope.syncNpcOverlayFromRecord(npc)); documentScope.npcOverlays.length = 0; nextNpcs.forEach((npc) => documentScope.npcOverlays.push(npc)); if (typeof scope.applyWorldChunkBackgroundState === "function" && scope.isWorldModeActive?.()) { scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {}); } if (typeof scope.applyWorldBookmarkState === "function" && scope.isWorldModeActive?.()) { scope.applyWorldBookmarkState(state?.worldBookmarks || []); } if (typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { scope.rebuildVisibleWorldChunksFromDocument(); } if (!config.deferRefresh) { refreshUiAfterHistoryMutation(); } } function ensureLayerForOperation(layerNumber) { const normalizedLayer = Number(layerNumber) || 0; let layerEntry = scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || null; if (layerEntry) { return layerEntry; } layerEntry = { layer: normalizedLayer, name: undefined, zIndex: 0, rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "), instanceIds: [], }; scope.roomLayers.push(layerEntry); scope.roomLayers = scope.roomLayers .slice() .sort((left, right) => Number(left.layer) - Number(right.layer)); return scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry; } function setStoredTileCharAt(layerNumber, tileX, tileY, nextStoredChar) { if (tileX < 0 || tileX >= scope.width || tileY < 0 || tileY >= scope.height) { return false; } const normalizedLayer = Number(layerNumber) || 0; const layerEntry = ensureLayerForOperation(normalizedLayer); const fillChar = normalizedLayer === 0 ? "." : " "; const rows = scope.normalizeRows(layerEntry.rows, fillChar); const row = rows[tileY] || fillChar.repeat(scope.width); const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar; if ((row.charAt(tileX) || fillChar) === safeChar) { return false; } rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1); layerEntry.rows = rows; if (typeof scope.syncWorldChunkCellFromLocalTile === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { scope.syncWorldChunkCellFromLocalTile(normalizedLayer, tileX, tileY, safeChar); } return true; } function resolveTileOperationCellCoord(cell) { if (typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { const worldX = Number(cell?.worldX); const worldY = Number(cell?.worldY); if (Number.isFinite(worldX) && Number.isFinite(worldY)) { return { x: Math.floor(worldX - (Number(scope.worldTileOffsetX) || 0)), y: Math.floor(worldY - (Number(scope.worldTileOffsetY) || 0)), }; } } return { x: Math.floor(Number(cell?.x) || 0), y: Math.floor(Number(cell?.y) || 0), }; } function applyTileCellsOperation(operation, direction) { const isRedo = direction !== "undo"; const nextBackgroundTileId = isRedo ? operation.afterBackgroundTileId : operation.beforeBackgroundTileId; if (nextBackgroundTileId !== undefined) { scope.backgroundTileId = scope.normalizeBackgroundTileId(nextBackgroundTileId); } const cells = Array.isArray(operation.cells) ? operation.cells : []; cells.forEach((cell) => { const resolvedCoord = resolveTileOperationCellCoord(cell, scope.width, scope.height); const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar; setStoredTileCharAt(cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar); }); if (nextBackgroundTileId !== undefined && typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { scope.rebuildVisibleWorldChunksFromDocument(); } scope.invalidateTileSurface(); } function buildNpcTargetEntries(operation, direction) { const useAfter = direction !== "undo"; const rawEntries = Array.isArray(operation.entries) ? operation.entries : []; return rawEntries .map((entry) => { const snapshot = useAfter ? entry.after : entry.before; const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex; if (!snapshot || typeof snapshot !== "object") { return null; } const cloned = scope.cloneNpcOverlays([cloneValue(snapshot)])[0]; if (!cloned) { return null; } scope.syncNpcOverlayFromRecord(cloned); return { npc: cloned, index: Math.max(0, Number(targetIndex) || 0), }; }) .filter((entry) => entry !== null) .sort((left, right) => left.index - right.index); } function applyNpcEntriesOperation(operation, direction) { const rawEntries = Array.isArray(operation.entries) ? operation.entries : []; const touchedPositions = []; rawEntries.forEach((entry) => { const beforePos = entry?.before && typeof entry.before === "object" ? entry.before : null; const afterPos = entry?.after && typeof entry.after === "object" ? entry.after : null; if (beforePos) { const x = Math.floor(Number(beforePos.x)); const y = Math.floor(Number(beforePos.y)); if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) { touchedPositions.push({ x, y }); } } if (afterPos) { const x = Math.floor(Number(afterPos.x)); const y = Math.floor(Number(afterPos.y)); if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) { touchedPositions.push({ x, y }); } } }); const affectedIds = new Set( rawEntries.flatMap((entry) => { const ids = []; const beforeId = String(entry?.before?.id || "").trim(); const afterId = String(entry?.after?.id || "").trim(); if (beforeId) { ids.push(beforeId); } if (afterId) { ids.push(afterId); } return ids; }), ); if (affectedIds.size === 0) { return; } const remainingNpcs = scope.npcOverlays.filter((npc) => !affectedIds.has(String(npc.id || "").trim())); scope.npcOverlays.length = 0; remainingNpcs.forEach((npc) => scope.npcOverlays.push(npc)); affectedIds.forEach((npcId) => { delete scope.npcImages[npcId]; }); const targetEntries = buildNpcTargetEntries(operation, direction); targetEntries.forEach((entry) => { const nextIndex = Math.max(0, Math.min(scope.npcOverlays.length, entry.index)); scope.ensureNpcImageLoaded(entry.npc); scope.npcOverlays.splice(nextIndex, 0, entry.npc); }); if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive() && touchedPositions.length > 0) { const xs = touchedPositions.map((entry) => entry.x); const ys = touchedPositions.map((entry) => entry.y); scope.rebuildWorldChunksForLocalBounds({ minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys), }); } } function applyOperation(operation, direction, options) { const config = options && typeof options === "object" ? options : {}; if (!operation || typeof operation !== "object") { return false; } if (operation.type === "tile_cells") { applyTileCellsOperation(operation, direction); } else if (operation.type === "npc_entries") { applyNpcEntriesOperation(operation, direction); } else { return false; } if (!config.deferRefresh) { refreshUiAfterHistoryMutation(); } return true; } function cloneHistoryState(state) { if (!state || typeof state !== "object") { return captureState(); } return { width: Math.max(1, Number(state.width) || 1), height: Math.max(1, Number(state.height) || 1), mapName: String(state.mapName || scope.mapId || ""), backgroundColor: documentScope.normalizeMapBackgroundColor(state.backgroundColor), backgroundTileId: documentScope.normalizeBackgroundTileId(state.backgroundTileId), heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)), layers: documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []), heightLayers: documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []), npcs: documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []), editorUi: documentScope.cloneEditorUiState(state.editorUi || {}), }; } function ensureLayerForStateOperation(state, layerNumber) { const normalizedLayer = Number(layerNumber) || 0; let layerEntry = state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || null; if (layerEntry) { return layerEntry; } layerEntry = { layer: normalizedLayer, name: undefined, zIndex: 0, rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "), instanceIds: [], }; state.layers.push(layerEntry); state.layers = state.layers .slice() .sort((left, right) => Number(left.layer) - Number(right.layer)); return state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry; } function setStoredTileCharAtInState(state, layerNumber, tileX, tileY, nextStoredChar) { if (tileX < 0 || tileX >= state.width || tileY < 0 || tileY >= state.height) { return false; } const normalizedLayer = Number(layerNumber) || 0; const layerEntry = ensureLayerForStateOperation(state, normalizedLayer); const fillChar = normalizedLayer === 0 ? "." : " "; const rows = scope.normalizeRows(layerEntry.rows, fillChar); const row = rows[tileY] || fillChar.repeat(state.width); const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar; if ((row.charAt(tileX) || fillChar) === safeChar) { return false; } rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1); layerEntry.rows = rows; return true; } function applyTileCellsOperationToState(state, operation, direction) { const nextState = cloneHistoryState(state); const isRedo = direction !== "undo"; const nextBackgroundTileId = isRedo ? operation.afterBackgroundTileId : operation.beforeBackgroundTileId; if (nextBackgroundTileId !== undefined) { nextState.backgroundTileId = documentScope.normalizeBackgroundTileId(nextBackgroundTileId); } const cells = Array.isArray(operation.cells) ? operation.cells : []; cells.forEach((cell) => { const resolvedCoord = resolveTileOperationCellCoord(cell, nextState.width, nextState.height); const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar; setStoredTileCharAtInState(nextState, cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar); }); return nextState; } function applyNpcEntriesOperationToState(state, operation, direction) { const nextState = cloneHistoryState(state); const rawEntries = Array.isArray(operation.entries) ? operation.entries : []; const affectedIds = new Set( rawEntries.flatMap((entry) => { const ids = []; const beforeId = String(entry?.before?.id || "").trim(); const afterId = String(entry?.after?.id || "").trim(); if (beforeId) { ids.push(beforeId); } if (afterId) { ids.push(afterId); } return ids; }), ); if (affectedIds.size === 0) { return nextState; } const useAfter = direction !== "undo"; const remainingNpcs = nextState.npcs.filter((npc) => !affectedIds.has(String(npc.id || "").trim())); const targetEntries = rawEntries .map((entry) => { const snapshot = useAfter ? entry.after : entry.before; const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex; if (!snapshot || typeof snapshot !== "object") { return null; } const cloned = documentScope.cloneNpcOverlays([cloneValue(snapshot)])[0]; if (!cloned) { return null; } documentScope.syncNpcOverlayFromRecord(cloned); return { npc: cloned, index: Math.max(0, Number(targetIndex) || 0), }; }) .filter((entry) => entry !== null) .sort((left, right) => left.index - right.index); nextState.npcs = remainingNpcs; targetEntries.forEach((entry) => { const nextIndex = Math.max(0, Math.min(nextState.npcs.length, entry.index)); nextState.npcs.splice(nextIndex, 0, entry.npc); }); return nextState; } function captureHistoryStateAtIndex(targetIndex) { const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1)); const targetEntry = scope.historyEntries[normalizedTargetIndex] || null; if (!targetEntry) { return captureState(); } if (targetEntry.state) { return cloneHistoryState(targetEntry.state); } const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex); if (snapshotIndex < 0) { return captureState(); } let nextState = cloneHistoryState(scope.historyEntries[snapshotIndex].state); for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) { const entry = scope.historyEntries[index]; if (!entry) { continue; } if (entry.state) { nextState = cloneHistoryState(entry.state); continue; } if (!entry.operation || typeof entry.operation !== "object") { continue; } if (entry.operation.type === "tile_cells") { nextState = applyTileCellsOperationToState(nextState, entry.operation, "redo"); } else if (entry.operation.type === "npc_entries") { nextState = applyNpcEntriesOperationToState(nextState, entry.operation, "redo"); } } return nextState; } function findNearestSnapshotIndex(targetIndex) { for (let index = Math.max(0, Number(targetIndex) || 0); index >= 0; index -= 1) { if (scope.historyEntries[index] && scope.historyEntries[index].state) { return index; } } return -1; } function restoreToHistoryIndex(targetIndex) { const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1)); const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex); if (snapshotIndex < 0) { return false; } applyState(scope.historyEntries[snapshotIndex].state, { deferRefresh: true }); for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) { const entry = scope.historyEntries[index]; if (!entry) { continue; } if (entry.state) { applyState(entry.state, { deferRefresh: true }); continue; } if (entry.operation) { applyOperation(entry.operation, "redo", { deferRefresh: true }); } } refreshUiAfterHistoryMutation(); return true; } function getStateSignature(state) { const layerSig = scope.cloneLayers(state.layers) .sort((a, b) => a.layer - b.layer) .map((layer) => ({ layer: layer.layer, name: typeof layer.name === "string" ? layer.name : "", rows: scope.normalizeRows(layer.rows, layer.layer === 0 ? "." : " "), })); const heightLayerSig = scope.cloneHeightLayers(state.heightLayers) .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))) .map((entry) => ({ id: String(entry.id || ""), name: typeof entry.name === "string" ? entry.name : "", z: Number(entry.z) || 1, x: Number(entry.x) || 0, y: Number(entry.y) || 0, rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [], })); const npcSig = scope.cloneNpcOverlays(state.npcs) .sort((a, b) => a.id.localeCompare(b.id)) .map((entry) => ({ id: entry.id, layer: Number(entry.layer) || 0, name: entry.name, spriteId: entry.spriteId, x: entry.x, y: entry.y, })); return JSON.stringify({ width: Number(state.width) || 1, height: Number(state.height) || 1, mapName: String(state.mapName || ""), backgroundColor: scope.normalizeMapBackgroundColor(state.backgroundColor), backgroundTileId: scope.normalizeBackgroundTileId(state.backgroundTileId), heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)), layerSig, heightLayerSig, npcSig, worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds) ? state.worldChunkBackgrounds : {}, editorUi: scope.cloneEditorUiState(state.editorUi || {}), }); } function formatCellCoord(cell) { return "(" + cell.x + "," + cell.y + ")"; } function formatHistoryLabel(entry) { const pairText = (entry.before || entry.after) ? (" (" + (entry.before || "?") + " -> " + (entry.after || "?") + ")") : ""; return entry.label + pairText; } function renderHistoryPreview() { const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null; if (!selectedEntry) { scope.historyPreviewEl.innerHTML = '