diff --git a/README.md b/README.md index 93e8bad..1211723 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,28 @@ outputProfile.saveSync('/path/to/my/new_snes_profile.rt4'); ## Changelog +### Version 1.1.0 (2024-07-04) + +- Added support for the following settings: + - `advanced.acquisition.audio_input.sampling.sample_rate` + - `advanced.acquisition.audio_input.sampling.preamp_gain` + - `advanced.acquisition.audio_input.source.input_override` + - `advanced.acquisition.audio_input.source.input_swap` + ### Version 1.0.1 (2024-07-03) - Documentation update ### Version 1.0.0 (2024-07-03) -- Support `Advanced -> System -> OSD/Firmware -> On Screen Display` -- Implement `header` as a `RetroTinkReadOnlySetting` -- Fixed Critical Bug https://github.com/boatmeme/rt4k-profile/issues/26 preventing files saved with this library from loading on RetroTink4k devices +- There are now **read-only** settings, such as `header`, which should be readable, but cannot be set +- Profiles saved with this library will now work on the actual device 🤦 +- Added support for the following settings: + - `advanced.system.osd_firmware.banner_image.load_banner` + - `advanced.system.osd_firmware.on_screen_display.position` + - `advanced.system.osd_firmware.on_screen_display.auto_off` + - `advanced.system.osd_firmware.on_screen_display.hide_input_res` + - `advanced.system.osd_firmware.on_screen_display.enable_debug_osd` ### Version 0.1.0 (2024-06-29) diff --git a/package.json b/package.json index 74a12ea..398769b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rt4k-profile", - "version": "1.0.1", + "version": "1.1.0", "description": "A Typescript Library for Reading and Writing RetroTINK-4k .rt4 Profiles", "main": "dist/index.js", "exports": { diff --git a/src/profile/RetroTinkProfile.ts b/src/profile/RetroTinkProfile.ts index e3b847f..725eb7e 100644 --- a/src/profile/RetroTinkProfile.ts +++ b/src/profile/RetroTinkProfile.ts @@ -66,6 +66,25 @@ export default class RetroTinkProfile { return RetroTinkProfile.sliceBytes(setting, this._bytes); } + private _setReadOnlyValues() { + const readOnlyValues = ( + Array.from(RetroTinkProfile._settings.values()).filter((s) => s instanceof RetroTinkReadOnlySetting) + ); + const byte_array = Array.from(this._bytes); + for (const setting of readOnlyValues) { + const derivedFromValues = + setting.derivedFrom.length > 0 ? Array.from(this.getValues(...setting.derivedFrom).values()) : []; + const value = setting.deriveValue(...derivedFromValues); + + let offset = 0; + for (const byteRange of setting.byteRanges) { + byte_array.splice(byteRange.address, byteRange.length, ...value.slice(offset, offset + byteRange.length)); + offset += byteRange.length; + } + } + this._bytes = new Uint8Array(byte_array); + } + private _setValueWithInstance(setting: RetroTinkSettingValue): void { if (!RetroTinkProfile._settings.has(setting.name)) throw new SettingNotSupportedError(setting.name); if (RetroTinkProfile._settings.get(setting.name) instanceof RetroTinkReadOnlySetting) @@ -217,7 +236,6 @@ export default class RetroTinkProfile { private static CRC_WRITE_INDEX = 32; private _getCrc(): Uint8Array { const START_INDEX = 128; - const crcValue = CRC16CCITT.calculate(this._bytes, START_INDEX); return new Uint8Array([crcValue & 0xff, (crcValue >> 8) & 0xff]); } @@ -227,11 +245,13 @@ export default class RetroTinkProfile { } async save(filePath: string, opts: WriteFileOptions = { createDirectoryIfNotExist: true }) { + this._setReadOnlyValues(); this._writeCrc(); return writeFileBinary(filePath, this._bytes, opts); } saveSync(filePath: string, opts: WriteFileOptions = { createDirectoryIfNotExist: true }) { + this._setReadOnlyValues(); this._writeCrc(); return writeFileBinarySync(filePath, this._bytes, opts); } @@ -241,6 +261,7 @@ export default class RetroTinkProfile { } getCrcString(): string { + this._setReadOnlyValues(); const [lowByte, highByte] = this._getCrc(); const crcValue = (highByte << 8) | lowByte; return `0x${crcValue.toString(16).toUpperCase().padStart(4, '0')}`; diff --git a/src/profile/__fixtures__/json_profiles.ts b/src/profile/__fixtures__/json_profiles.ts index 59718f5..19fdafd 100644 --- a/src/profile/__fixtures__/json_profiles.ts +++ b/src/profile/__fixtures__/json_profiles.ts @@ -1,4 +1,4 @@ -export const unpretty_json_str = `{"advanced":{"effects":{"mask":{"enabled":true,"strength":-4,"path":"Mono Masks/A Grille Medium Mono.bmp"}},"system":{"osd_firmware":{"banner_image":{"load_banner":""},"on_screen_display":{"position":"Left","auto_off":"Off","hide_input_res":false,"enable_debug_osd":"Off"}}}},"input":"HDMI","output":{"resolution":"4K60","transmitter":{"hdr":"Off","colorimetry":"Auto-Rec.709","rgb_range":"Full","sync_lock":"Triple Buffer","vrr":"Off","deep_color":false}}}`; +export const unpretty_json_str = `{"advanced":{"effects":{"mask":{"enabled":true,"strength":-4,"path":"Mono Masks/A Grille Medium Mono.bmp"}},"acquisition":{"audio_input":{"sampling":{"sample_rate":"48 kHz","preamp_gain":"+0.0 dB"},"source":{"input_override":"Off","input_swap":"Off"}}},"system":{"osd_firmware":{"banner_image":{"load_banner":""},"on_screen_display":{"position":"Left","auto_off":"Off","hide_input_res":false,"enable_debug_osd":"Off"}}}},"input":"HDMI","output":{"resolution":"4K60","transmitter":{"hdr":"Off","colorimetry":"Auto-Rec.709","rgb_range":"Full","sync_lock":"Triple Buffer","vrr":"Off","deep_color":false}}}`; export const pretty_json_str = `{ "advanced": { "effects": { @@ -8,6 +8,18 @@ export const pretty_json_str = `{ "path": "Mono Masks/A Grille Medium Mono.bmp" } }, + "acquisition": { + "audio_input": { + "sampling": { + "sample_rate": "48 kHz", + "preamp_gain": "+0.0 dB" + }, + "source": { + "input_override": "Off", + "input_swap": "Off" + } + } + }, "system": { "osd_firmware": { "banner_image": { diff --git a/src/settings/RetroTinkSetting.ts b/src/settings/RetroTinkSetting.ts index fff0704..2419d64 100644 --- a/src/settings/RetroTinkSetting.ts +++ b/src/settings/RetroTinkSetting.ts @@ -20,6 +20,11 @@ interface RetroTinkSettingParams { enums?: RetroTinkEnumValue[]; } +interface RetroTinkReadOnlySettingParams extends RetroTinkSettingParams { + derivedFrom: RetroTinkSettingName[]; + deriveValue: (...values: RetroTinkSettingValue[]) => Uint8Array; +} + interface RetroTinkEnumValue { name: string; value: Uint8Array; @@ -63,7 +68,15 @@ export class RetroTinkSetting { } } -export class RetroTinkReadOnlySetting extends RetroTinkSetting {} +export class RetroTinkReadOnlySetting extends RetroTinkSetting { + derivedFrom: RetroTinkSettingName[]; + deriveValue: (...values: RetroTinkSettingValue[]) => Uint8Array; + constructor(params: RetroTinkReadOnlySettingParams) { + super(params); + this.derivedFrom = params.derivedFrom; + this.deriveValue = params.deriveValue; + } +} export type RetroTinkSettingsValuesPlainObject = { [key: string]: string | number | boolean | RetroTinkSettingsValuesPlainObject; diff --git a/src/settings/Schema.spec.ts b/src/settings/Schema.spec.ts new file mode 100644 index 0000000..b4c26d2 --- /dev/null +++ b/src/settings/Schema.spec.ts @@ -0,0 +1,113 @@ +import { RetroTinkReadOnlySetting, RetroTinkSettingValue } from './RetroTinkSetting'; +import { RetroTinkSettingName, RetroTinkSettingsVersion } from './Schema'; +import { SettingValidationError } from '../exceptions/RetroTinkProfileException'; + +describe('Schema', () => { + describe('1.4.2', () => { + describe('derived settings', () => { + describe('input.audio', () => { + it('should vary with input', () => { + const settings = RetroTinkSettingsVersion['1.4.2']; + const inputs = settings.get('input').enums?.reduce((acc, e) => ({ ...acc, [e.name]: e.value }), {}) || {}; + const audio_input_override = new RetroTinkSettingValue( + settings.get('advanced.acquisition.audio_input.source.input_override'), + new Uint8Array([0]), + ); + const input_audio = settings.get('input.audio' as RetroTinkSettingName); + + let input = new RetroTinkSettingValue(settings.get('input'), inputs['HDMI']); + let [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(5); + + input = new RetroTinkSettingValue(settings.get('input'), inputs['Front|Composite']); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(3); + + input = new RetroTinkSettingValue(settings.get('input'), inputs['RCA|RGsB']); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(0); + + input = new RetroTinkSettingValue(settings.get('input'), inputs['SCART|YPbPr']); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(2); + + input = new RetroTinkSettingValue(settings.get('input'), inputs['HD-15|RGBHV']); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(1); + }); + it('should vary with audio input override', () => { + const settings = RetroTinkSettingsVersion['1.4.2']; + const inputs = settings.get('input').enums?.reduce((acc, e) => ({ ...acc, [e.name]: e.value }), {}) || {}; + const inputOverrides = + settings + .get('advanced.acquisition.audio_input.source.input_override') + .enums?.reduce((acc, e) => ({ ...acc, [e.name]: e.value }), {}) || {}; + const input_audio = settings.get('input.audio' as RetroTinkSettingName); + const input = new RetroTinkSettingValue(settings.get('input'), inputs['HDMI']); + + let audio_input_override = new RetroTinkSettingValue( + settings.get('advanced.acquisition.audio_input.source.input_override'), + inputOverrides['RCA'], + ); + let [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(0); + + audio_input_override = new RetroTinkSettingValue( + settings.get('advanced.acquisition.audio_input.source.input_override'), + inputOverrides['HD-15'], + ); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(1); + + audio_input_override = new RetroTinkSettingValue( + settings.get('advanced.acquisition.audio_input.source.input_override'), + inputOverrides['SCART'], + ); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(2); + + audio_input_override = new RetroTinkSettingValue( + settings.get('advanced.acquisition.audio_input.source.input_override'), + inputOverrides['Front'], + ); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(3); + + audio_input_override = new RetroTinkSettingValue( + settings.get('advanced.acquisition.audio_input.source.input_override'), + inputOverrides['S/PDIF'], + ); + [value] = input_audio.deriveValue(audio_input_override, input); + expect(value).toEqual(4); + }); + + it('should throw if there is a bad input value', () => { + const settings = RetroTinkSettingsVersion['1.4.2']; + const audio_input_override = new RetroTinkSettingValue( + settings.get('advanced.acquisition.audio_input.source.input_override'), + new Uint8Array([0]), + ); + const input_audio = settings.get('input.audio' as RetroTinkSettingName); + + expect(() => + input_audio.deriveValue(audio_input_override, { asInt: () => 69 } as RetroTinkSettingValue), + ).toThrow(SettingValidationError); + }); + }); + }); + describe('dynamically generated values', () => { + describe('advanced.acquisition.audio_input.sampling.preamp_gain', () => { + const settings = RetroTinkSettingsVersion['1.4.2']; + const gain = settings.get('advanced.acquisition.audio_input.sampling.preamp_gain'); + const first = gain.enums?.find(({ name }) => name == '-24.0 dB'); + const last = gain.enums?.find(({ name }) => name == '+28.0 dB'); + const middle = gain.enums?.find(({ name }) => name == '+0.0 dB'); + + expect(gain.enums?.length).toBe(105); + expect(first?.value).toEqual(new Uint8Array([208])); + expect(last?.value).toEqual(new Uint8Array([56])); + expect(middle?.value).toEqual(new Uint8Array([0])); + }); + }); + }); +}); diff --git a/src/settings/Schema.ts b/src/settings/Schema.ts index 0e65cd3..5b51a2d 100644 --- a/src/settings/Schema.ts +++ b/src/settings/Schema.ts @@ -1,5 +1,11 @@ +import { SettingValidationError } from '../exceptions/RetroTinkProfileException'; import { DataType } from './DataType'; -import { RetroTinkReadOnlySetting, RetroTinkSetting, RetroTinkSettings } from './RetroTinkSetting'; +import { + RetroTinkReadOnlySetting, + RetroTinkSetting, + RetroTinkSettingValue, + RetroTinkSettings, +} from './RetroTinkSetting'; export type Primitive = string | number | boolean; @@ -50,6 +56,18 @@ export type RetroTinkSettingsSchema = { strength: number; }; }; + acquisition: { + audio_input: { + sampling: { + sample_rate: string; + preamp_gain: string; + }; + source: { + input_override: string; + input_swap: string; + }; + }; + }; system: { osd_firmware: { banner_image: { @@ -73,6 +91,8 @@ export const RetroTinkSettingsVersion = { desc: 'File Header (Read-Only)', byteRanges: [{ address: 0x0000, length: 12 }], type: DataType.STR, + derivedFrom: [], + deriveValue: () => new Uint8Array([82, 84, 52, 75, 32, 80, 114, 111, 102, 105, 108, 101]), }), new RetroTinkSetting({ name: 'advanced.effects.mask.enabled', @@ -92,6 +112,82 @@ export const RetroTinkSettingsVersion = { byteRanges: [{ address: 0x0090, length: 256 }], type: DataType.STR, }), + new RetroTinkSetting({ + name: 'advanced.acquisition.audio_input.sampling.sample_rate', + desc: 'Advanced -> Acquisition -> Audio Input -> Sampling -> Sample Rate', + byteRanges: [{ address: 0x1624, length: 1 }], + type: DataType.ENUM, + enums: [ + { name: '48 kHz', value: new Uint8Array([0]) }, + { name: '96 kHz', value: new Uint8Array([1]) }, + ], + }), + new RetroTinkSetting({ + name: 'advanced.acquisition.audio_input.sampling.preamp_gain', + desc: 'Advanced -> Acquisition -> Audio Input -> Sampling -> Pre-amp Gain', + byteRanges: [{ address: 0x1610, length: 1 }], + type: DataType.ENUM, + // Generates 105 enum entries for pre-amp gain from -24.0 dB to +28.0 dB in 0.5 dB steps + enums: Array.from({ length: 105 }, (_, i) => ({ + name: `${i * 0.5 - 24 >= 0 ? '+' : ''}${(i * 0.5 - 24).toFixed(1)} dB`, + value: new Uint8Array([(208 + i) & 255]), + })), + }), + new RetroTinkReadOnlySetting({ + name: 'input.audio' as RetroTinkSettingName, + desc: 'Audio Input (Read-Only)', + byteRanges: [{ address: 0x0368, length: 1 }], + type: DataType.INT, + derivedFrom: ['advanced.acquisition.audio_input.source.input_override', 'input'], + deriveValue: (...[audio_input_override, source_input]: RetroTinkSettingValue[]) => { + const overrideVal = audio_input_override.asInt(); + const sourceVal = source_input.asInt(); + if (overrideVal == 0) { + switch (true) { + case sourceVal === 0: + return new Uint8Array([5]); + case [3, 4].includes(sourceVal): + return new Uint8Array([3]); + case [7, 8, 9].includes(sourceVal): + return new Uint8Array([0]); + case [12, 13, 14, 15, 16, 17].includes(sourceVal): + return new Uint8Array([2]); + case [20, 21, 22, 23, 24, 25, 26, 27].includes(sourceVal): + return new Uint8Array([1]); + default: + throw new SettingValidationError('input', sourceVal, `unexpected 'input' value`); + } + } else { + return new Uint8Array([overrideVal - 1]); + } + }, + }), + new RetroTinkSetting({ + name: 'advanced.acquisition.audio_input.source.input_override', + desc: 'Advanced -> Acquisition -> Audio Input -> Source -> Input Override', + byteRanges: [{ address: 0x1618, length: 1 }], + type: DataType.ENUM, + enums: [ + { name: 'Off', value: new Uint8Array([0]) }, + { name: 'RCA', value: new Uint8Array([1]) }, + { name: 'HD-15', value: new Uint8Array([2]) }, + { name: 'SCART', value: new Uint8Array([3]) }, + { name: 'Front', value: new Uint8Array([4]) }, + { name: 'S/PDIF', value: new Uint8Array([5]) }, + ], + }), + new RetroTinkSetting({ + name: 'advanced.acquisition.audio_input.source.input_swap', + desc: 'Advanced -> Acquisition -> Audio Input -> Source -> Input Swap', + byteRanges: [{ address: 0x1620, length: 1 }], + type: DataType.ENUM, + enums: [ + { name: 'Off', value: new Uint8Array([0]) }, + { name: 'Mono (Left)', value: new Uint8Array([1]) }, + { name: 'Mono (Right)', value: new Uint8Array([2]) }, + { name: 'L/R Swap', value: new Uint8Array([3]) }, + ], + }), new RetroTinkSetting({ name: 'advanced.system.osd_firmware.banner_image.load_banner', desc: 'Advanced -> System -> OSD/Firmware -> On Screen Display -> Load Banner', @@ -150,32 +246,29 @@ export const RetroTinkSettingsVersion = { new RetroTinkSetting({ name: 'input', desc: 'Input', - byteRanges: [ - { address: 0x0368, length: 1 }, - { address: 0x5869, length: 1 }, - ], + byteRanges: [{ address: 0x5869, length: 1 }], type: DataType.ENUM, enums: [ - { name: 'HDMI', value: new Uint8Array([5, 0]) }, - { name: 'Front|Composite', value: new Uint8Array([3, 3]) }, - { name: 'Front|S-Video', value: new Uint8Array([3, 4]) }, - { name: 'RCA|YPbPr', value: new Uint8Array([0, 7]) }, - { name: 'RCA|RGsB', value: new Uint8Array([0, 8]) }, - { name: 'RCA|CVBS on Green', value: new Uint8Array([0, 9]) }, - { name: 'SCART|RGBS (75 Ohm)', value: new Uint8Array([2, 12]) }, - { name: 'SCART|RGsB', value: new Uint8Array([2, 13]) }, - { name: 'SCART|YPbPr', value: new Uint8Array([2, 14]) }, - { name: 'SCART|CVBS on Pin 20', value: new Uint8Array([2, 15]) }, - { name: 'SCART|CVBS on Green', value: new Uint8Array([2, 16]) }, - { name: 'SCART|Y/C on Pin 20/Red', value: new Uint8Array([2, 17]) }, - { name: 'HD-15|RGBHV', value: new Uint8Array([1, 20]) }, - { name: 'HD-15|RGBS', value: new Uint8Array([1, 21]) }, - { name: 'HD-15|RGsB', value: new Uint8Array([1, 22]) }, - { name: 'HD-15|YPbPr', value: new Uint8Array([1, 23]) }, - { name: 'HD-15|CVBS on Hsync', value: new Uint8Array([1, 24]) }, - { name: 'HD-15|CVBS on Green', value: new Uint8Array([1, 25]) }, - { name: 'HD-15|Y/C on Green/Red', value: new Uint8Array([1, 26]) }, - { name: 'HD-15|Y/C on G/R (Enh.)', value: new Uint8Array([1, 27]) }, + { name: 'HDMI', value: new Uint8Array([0]) }, + { name: 'Front|Composite', value: new Uint8Array([3]) }, + { name: 'Front|S-Video', value: new Uint8Array([4]) }, + { name: 'RCA|YPbPr', value: new Uint8Array([7]) }, + { name: 'RCA|RGsB', value: new Uint8Array([8]) }, + { name: 'RCA|CVBS on Green', value: new Uint8Array([9]) }, + { name: 'SCART|RGBS (75 Ohm)', value: new Uint8Array([12]) }, + { name: 'SCART|RGsB', value: new Uint8Array([13]) }, + { name: 'SCART|YPbPr', value: new Uint8Array([14]) }, + { name: 'SCART|CVBS on Pin 20', value: new Uint8Array([15]) }, + { name: 'SCART|CVBS on Green', value: new Uint8Array([16]) }, + { name: 'SCART|Y/C on Pin 20/Red', value: new Uint8Array([17]) }, + { name: 'HD-15|RGBHV', value: new Uint8Array([20]) }, + { name: 'HD-15|RGBS', value: new Uint8Array([21]) }, + { name: 'HD-15|RGsB', value: new Uint8Array([22]) }, + { name: 'HD-15|YPbPr', value: new Uint8Array([23]) }, + { name: 'HD-15|CVBS on Hsync', value: new Uint8Array([24]) }, + { name: 'HD-15|CVBS on Green', value: new Uint8Array([25]) }, + { name: 'HD-15|Y/C on Green/Red', value: new Uint8Array([26]) }, + { name: 'HD-15|Y/C on G/R (Enh.)', value: new Uint8Array([27]) }, ], }), new RetroTinkSetting({