mirror of
https://github.com/libretro/scummvm.git
synced 2024-12-15 22:28:10 +00:00
1058 lines
33 KiB
C++
1058 lines
33 KiB
C++
/* ScummVM - Graphic Adventure Engine
|
|
*
|
|
* ScummVM is the legal property of its developers, whose names
|
|
* are too numerous to list here. Please refer to the COPYRIGHT
|
|
* file distributed with this source distribution.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*
|
|
*/
|
|
|
|
#include "common/system.h"
|
|
#include "common/hashmap.h"
|
|
|
|
#include "graphics/managed_surface.h"
|
|
#include "graphics/surface.h"
|
|
|
|
#include "mtropolis/assets.h"
|
|
#include "mtropolis/detection.h"
|
|
#include "mtropolis/elements.h"
|
|
#include "mtropolis/hacks.h"
|
|
#include "mtropolis/runtime.h"
|
|
#include "mtropolis/modifiers.h"
|
|
#include "mtropolis/saveload.h"
|
|
|
|
namespace MTropolis {
|
|
|
|
Hacks::Hacks() {
|
|
ignoreMismatchedProjectNameInObjectLookups = false;
|
|
removeQuickTimeEdits = false;
|
|
midiVolumeScale = 256;
|
|
minTransitionDuration = 0;
|
|
ignoreMToonMaintainRateFlag = false;
|
|
mtiVariableReferencesHack = false;
|
|
mtiSceneReturnHack = false;
|
|
}
|
|
|
|
Hacks::~Hacks() {
|
|
}
|
|
|
|
void Hacks::addStructuralHooks(uint32 guid, const Common::SharedPtr<StructuralHooks> &hooks) {
|
|
structuralHooks[guid] = hooks;
|
|
}
|
|
|
|
void Hacks::addModifierHooks(uint32 guid, const Common::SharedPtr<ModifierHooks> &hooks) {
|
|
modifierHooks[guid] = hooks;
|
|
}
|
|
|
|
void Hacks::addAssetHooks(const Common::SharedPtr<AssetHooks> &hooks) {
|
|
assetHooks.push_back(hooks);
|
|
}
|
|
|
|
void Hacks::addSceneTransitionHooks(const Common::SharedPtr<SceneTransitionHooks> &hooks) {
|
|
sceneTransitionHooks.push_back(hooks);
|
|
}
|
|
|
|
void Hacks::addSaveLoadHooks(const Common::SharedPtr<SaveLoadHooks> &hooks) {
|
|
saveLoadHooks.push_back(hooks);
|
|
}
|
|
|
|
void Hacks::addSaveLoadMechanismHooks(const Common::SharedPtr<SaveLoadMechanismHooks> &hooks) {
|
|
saveLoadMechanismHooks.push_back(hooks);
|
|
}
|
|
|
|
namespace HackSuites {
|
|
|
|
class ObsidianCorruptedAirTowerTransitionFix : public AssetHooks {
|
|
public:
|
|
void onLoaded(Asset *asset, const Common::String &name) override;
|
|
};
|
|
|
|
void ObsidianCorruptedAirTowerTransitionFix::onLoaded(Asset *asset, const Common::String &name) {
|
|
if (asset->getAssetType() == kAssetTypeMovie && name == "A105_132.01Fxx.trn") {
|
|
static_cast<MovieAsset *>(asset)->addDamagedFrame(35);
|
|
}
|
|
}
|
|
|
|
class ObsidianInventoryWindscreenHooks : public StructuralHooks {
|
|
public:
|
|
void onSetPosition(Runtime *runtime, Structural *structural, Common::Point &pt) override;
|
|
};
|
|
|
|
void ObsidianInventoryWindscreenHooks::onSetPosition(Runtime *runtime, Structural *structural, Common::Point &pt) {
|
|
if (pt.y < 480) {
|
|
// Set direct to screen so it draws over cinematics
|
|
static_cast<VisualElement *>(structural)->setDirectToScreen(true);
|
|
|
|
// Move in-bounds
|
|
pt.y -= 60;
|
|
}
|
|
}
|
|
|
|
class ObsidianSecurityFormWidescreenHooks : public StructuralHooks {
|
|
public:
|
|
void onSetPosition(Runtime *runtime, Structural *structural, Common::Point &pt) override;
|
|
|
|
private:
|
|
Common::Array<uint32> _hiddenCards;
|
|
};
|
|
|
|
void ObsidianSecurityFormWidescreenHooks::onSetPosition(Runtime *runtime, Structural *structural, Common::Point &pt) {
|
|
bool cardVisibility = (pt.y > 480);
|
|
|
|
// Originally tried manipulating layer order but that's actually not a good solution because
|
|
// the form graphic is not actually dismissed until the cinematic completes. It's normally not
|
|
// visible because the cinematic is drawn over it, but managing that vis-a-vis the cards is a mess,
|
|
// and the form graphic actually includes a bit of area to the left due to the vidbot's arm being
|
|
// on the desk, which partially overlaps the cards, but not completely.
|
|
Structural *subsection = structural->getParent()->getParent();
|
|
assert(subsection->isSubsection());
|
|
|
|
Structural *sharedScene = subsection->getChildren()[0].get();
|
|
assert(sharedScene);
|
|
|
|
Structural *cards = nullptr;
|
|
for (const Common::SharedPtr<Structural> &child : sharedScene->getChildren()) {
|
|
if (child->getName() == "Inventory Cards") {
|
|
cards = child.get();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!cardVisibility)
|
|
_hiddenCards.clear();
|
|
|
|
if (cards) {
|
|
for (const Common::SharedPtr<Structural> &child : cards->getChildren()) {
|
|
assert(child->isElement() && static_cast<Element *>(child.get())->isVisual());
|
|
|
|
VisualElement *card = static_cast<VisualElement *>(child.get());
|
|
|
|
if (cardVisibility) {
|
|
if (Common::find(_hiddenCards.begin(), _hiddenCards.end(), card->getStaticGUID()) != _hiddenCards.end())
|
|
card->setVisible(runtime, true);
|
|
} else {
|
|
if (card->isVisible()) {
|
|
_hiddenCards.push_back(card->getStaticGUID());
|
|
card->setVisible(runtime, false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cardVisibility)
|
|
_hiddenCards.clear();
|
|
}
|
|
|
|
class ObsidianRSGLogoAnamorphicFilter : public MovieResizeFilter {
|
|
public:
|
|
ObsidianRSGLogoAnamorphicFilter();
|
|
|
|
Common::SharedPtr<Graphics::ManagedSurface> scaleFrame(const Graphics::Surface &surface, uint32 timestamp) const override;
|
|
|
|
private:
|
|
template<class TPixel>
|
|
void anamorphicScaleFrameTyped(const Graphics::Surface &src, Graphics::ManagedSurface &dest) const;
|
|
|
|
static double anamorphicCurve(double d);
|
|
static double inverseAnamorphicCurve(double d);
|
|
|
|
template<class TPixel>
|
|
void halveWidthTyped(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dest) const;
|
|
|
|
template<class TPixel>
|
|
void halveHeightTyped(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dest) const;
|
|
|
|
Common::Array<uint> _xCoordinates;
|
|
Common::Array<uint> _yCoordinates;
|
|
};
|
|
|
|
ObsidianRSGLogoAnamorphicFilter::ObsidianRSGLogoAnamorphicFilter() {
|
|
// Anamorphic rescale, keeps the RSG logo proportional but preserves the vertical spacing!
|
|
// We use an anamorphic curve of y=x+x^2 which ensures the derivative at 0 is 1, meaning
|
|
// the rate of change stays constant at with the unfiltered pixels at the edge of the filter.
|
|
const uint unscaledWidth = 640;
|
|
const uint unscaledHeight = 480;
|
|
|
|
const uint scaledWidth = 1280;
|
|
const uint scaledHeight = 720;
|
|
|
|
_xCoordinates.resize(scaledWidth);
|
|
_yCoordinates.resize(scaledHeight);
|
|
|
|
// Margin in pixels on the side of the original image to apply filter
|
|
const double scalingFactor = static_cast<double>(scaledHeight) / static_cast<double>(unscaledHeight);
|
|
const double invScalingFactor = 1.0 / scalingFactor;
|
|
const double sideMarginInOriginalImage = 90.0;
|
|
|
|
const double sideMarginInScaledImage = (static_cast<double>(scaledWidth) - ((static_cast<double>(unscaledWidth) - sideMarginInOriginalImage * 2.0) * scalingFactor)) * 0.5;
|
|
|
|
const double originalMarginHeightFraction = sideMarginInOriginalImage / static_cast<double>(unscaledHeight);
|
|
const double scaledMarginHeightFraction = sideMarginInScaledImage / static_cast<double>(scaledHeight);
|
|
|
|
const double targetCurveRatio = scaledMarginHeightFraction / originalMarginHeightFraction;
|
|
|
|
// (x + x^2) / x = targetCurveRatio
|
|
// (x + x^2) = targetCurveRatio * x
|
|
// 1 + x = targetCurveRatio
|
|
|
|
const double xCurveRatio = targetCurveRatio - 1.0;
|
|
const double yCurveRatio = anamorphicCurve(xCurveRatio);
|
|
|
|
const double rightMarginStart = static_cast<double>(scaledWidth) - sideMarginInScaledImage;
|
|
|
|
for (uint i = 0; i < scaledWidth; i++) {
|
|
double pixelCenterX = static_cast<double>(i) + 0.5;
|
|
double originalImagePixelCenter = 0.0;
|
|
if (pixelCenterX < sideMarginInScaledImage) {
|
|
double marginFraction = 1.0 - pixelCenterX / sideMarginInScaledImage;
|
|
double marginCurveY = marginFraction * yCurveRatio;
|
|
double marginCurveX = inverseAnamorphicCurve(marginCurveY);
|
|
double multiplier = 1.0 - marginCurveX / xCurveRatio;
|
|
originalImagePixelCenter = multiplier * sideMarginInOriginalImage;
|
|
} else if (pixelCenterX > rightMarginStart) {
|
|
double marginFraction = 1.0 - (static_cast<double>(scaledWidth) - pixelCenterX) / sideMarginInScaledImage;
|
|
double marginCurveY = marginFraction * yCurveRatio;
|
|
double marginCurveX = inverseAnamorphicCurve(marginCurveY);
|
|
double multiplier = 1.0 - marginCurveX / xCurveRatio;
|
|
originalImagePixelCenter = static_cast<double>(unscaledWidth) - multiplier * sideMarginInOriginalImage;
|
|
} else {
|
|
double offsetFromCenter = pixelCenterX - (static_cast<double>(scaledWidth) * 0.5);
|
|
double offsetFromCenterInOriginalImage = offsetFromCenter * invScalingFactor;
|
|
originalImagePixelCenter = static_cast<double>(unscaledWidth) * 0.5 + offsetFromCenterInOriginalImage;
|
|
}
|
|
|
|
double srcPixelX = floor(originalImagePixelCenter);
|
|
if (srcPixelX < 0.0)
|
|
srcPixelX = 0.0;
|
|
else if (srcPixelX >= static_cast<double>(unscaledWidth))
|
|
srcPixelX = static_cast<double>(unscaledWidth - 1);
|
|
|
|
_xCoordinates[i] = static_cast<uint>(srcPixelX);
|
|
}
|
|
|
|
for (uint i = 0; i < scaledHeight; i++)
|
|
_yCoordinates[i] = (2 * i + 1) * unscaledHeight / (scaledHeight * 2);
|
|
}
|
|
|
|
template<class TPixel>
|
|
void ObsidianRSGLogoAnamorphicFilter::anamorphicScaleFrameTyped(const Graphics::Surface &src, Graphics::ManagedSurface &dest) const {
|
|
const uint width = _xCoordinates.size();
|
|
const uint height = _yCoordinates.size();
|
|
|
|
const uint *xCoordinates = &_xCoordinates[0];
|
|
const uint *yCoordinates = &_yCoordinates[0];
|
|
|
|
assert(width == static_cast<uint>(dest.w));
|
|
assert(height == static_cast<uint>(dest.h));
|
|
|
|
for (uint row = 0; row < height; row++) {
|
|
const TPixel *srcRow = static_cast<const TPixel *>(src.getBasePtr(0, yCoordinates[row]));
|
|
TPixel *destRow = static_cast<TPixel *>(dest.getBasePtr(0, row));
|
|
|
|
for (uint col = 0; col < width; col++)
|
|
destRow[col] = srcRow[xCoordinates[col]];
|
|
}
|
|
}
|
|
|
|
double ObsidianRSGLogoAnamorphicFilter::anamorphicCurve(double d) {
|
|
return d + d * d;
|
|
}
|
|
|
|
double ObsidianRSGLogoAnamorphicFilter::inverseAnamorphicCurve(double d) {
|
|
return -0.5 + sqrt(0.25 + d);
|
|
}
|
|
|
|
template<class TPixel>
|
|
void ObsidianRSGLogoAnamorphicFilter::halveWidthTyped(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dest) const {
|
|
const uint widthHigh = src.w;
|
|
const uint widthLow = dest.w;
|
|
const uint height = src.h;
|
|
|
|
assert(widthLow * 2 == widthHigh);
|
|
assert(dest.h == src.h);
|
|
|
|
const Graphics::PixelFormat fmt = src.format;
|
|
|
|
for (uint row = 0; row < height; row++) {
|
|
const TPixel *srcRow = static_cast<const TPixel *>(src.getBasePtr(0, row));
|
|
TPixel *destRow = static_cast<TPixel *>(dest.getBasePtr(0, row));
|
|
|
|
for (uint col = 0; col < widthLow; col++) {
|
|
uint32 col1 = srcRow[col * 2];
|
|
uint32 col2 = srcRow[col * 2 + 1];
|
|
|
|
uint8 r1, g1, b1;
|
|
fmt.colorToRGB(col1, r1, g1, b1);
|
|
|
|
uint8 r2, g2, b2;
|
|
fmt.colorToRGB(col2, r2, g2, b2);
|
|
|
|
destRow[col] = fmt.RGBToColor((r1 + r2) >> 1, (g1 + g2) >> 1, (b1 + b2) >> 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
template<class TPixel>
|
|
void ObsidianRSGLogoAnamorphicFilter::halveHeightTyped(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dest) const {
|
|
const uint heightHigh = src.h;
|
|
const uint heightLow = dest.h;
|
|
const uint width = src.w;
|
|
|
|
assert(heightLow * 2 == heightHigh);
|
|
assert(dest.w == src.w);
|
|
|
|
const Graphics::PixelFormat fmt = src.format;
|
|
|
|
for (uint row = 0; row < heightLow; row++) {
|
|
const TPixel *srcRow1 = static_cast<const TPixel *>(src.getBasePtr(0, row * 2));
|
|
const TPixel *srcRow2 = static_cast<const TPixel *>(src.getBasePtr(0, row * 2 + 1));
|
|
TPixel *destRow = static_cast<TPixel *>(dest.getBasePtr(0, row));
|
|
|
|
for (uint col = 0; col < width; col++) {
|
|
uint32 col1 = srcRow1[col];
|
|
uint32 col2 = srcRow2[col];
|
|
|
|
uint8 r1, g1, b1;
|
|
fmt.colorToRGB(col1, r1, g1, b1);
|
|
|
|
uint8 r2, g2, b2;
|
|
fmt.colorToRGB(col2, r2, g2, b2);
|
|
|
|
destRow[col] = fmt.RGBToColor((r1 + r2) >> 1, (g1 + g2) >> 1, (b1 + b2) >> 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
Common::SharedPtr<Graphics::ManagedSurface> ObsidianRSGLogoAnamorphicFilter::scaleFrame(const Graphics::Surface &surface, uint32 timestamp) const {
|
|
Common::SharedPtr<Graphics::ManagedSurface> result(new Graphics::ManagedSurface());
|
|
result->create(_xCoordinates.size() / 2, _yCoordinates.size() / 2, surface.format);
|
|
|
|
Common::SharedPtr<Graphics::ManagedSurface> temp1(new Graphics::ManagedSurface());
|
|
Common::SharedPtr<Graphics::ManagedSurface> temp2(new Graphics::ManagedSurface());
|
|
|
|
temp1->create(_xCoordinates.size(), _yCoordinates.size(), surface.format);
|
|
temp2->create(_xCoordinates.size() / 2, _yCoordinates.size(), surface.format);
|
|
|
|
if (surface.format.bytesPerPixel == 1) {
|
|
anamorphicScaleFrameTyped<uint8>(surface, *temp1);
|
|
halveWidthTyped<uint8>(*temp1, *temp2);
|
|
halveHeightTyped<uint8>(*temp2, *result);
|
|
} else if (surface.format.bytesPerPixel == 2) {
|
|
anamorphicScaleFrameTyped<uint16>(surface, *temp1);
|
|
halveWidthTyped<uint16>(*temp1, *temp2);
|
|
halveHeightTyped<uint16>(*temp2, *result);
|
|
} else if (surface.format.bytesPerPixel == 4) {
|
|
anamorphicScaleFrameTyped<uint32>(surface, *temp1);
|
|
halveWidthTyped<uint32>(*temp1, *temp2);
|
|
halveHeightTyped<uint32>(*temp2, *result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
class ObsidianRSGLogoWidescreenHooks : public StructuralHooks {
|
|
public:
|
|
void onCreate(Structural *structural) override;
|
|
};
|
|
|
|
void ObsidianRSGLogoWidescreenHooks::onCreate(Structural *structural) {
|
|
MovieElement *movie = static_cast<MovieElement *>(structural);
|
|
movie->setRelativeRect(Common::Rect(0, 60, 640, 420));
|
|
movie->setResizeFilter(Common::SharedPtr<MovieResizeFilter>(new ObsidianRSGLogoAnamorphicFilter()));
|
|
}
|
|
|
|
class ObsidianSaveScreenshotHooks : public SceneTransitionHooks {
|
|
public:
|
|
void onSceneTransitionSetup(Runtime *runtime, const Common::WeakPtr<Structural> &oldScene, const Common::WeakPtr<Structural> &newScene) override;
|
|
};
|
|
|
|
void ObsidianSaveScreenshotHooks::onSceneTransitionSetup(Runtime *runtime, const Common::WeakPtr<Structural> &oldScene, const Common::WeakPtr<Structural> &newScene) {
|
|
Structural *newScenePtr = newScene.lock().get();
|
|
|
|
if (!newScenePtr)
|
|
return;
|
|
|
|
if (newScenePtr->getName() == "Game_Screen") {
|
|
Window *mainWindow = runtime->getMainWindow().lock().get();
|
|
if (mainWindow) {
|
|
Common::SharedPtr<Graphics::ManagedSurface> mainWindowSurface = mainWindow->getSurface();
|
|
Common::SharedPtr<Graphics::ManagedSurface> screenshot(new Graphics::ManagedSurface());
|
|
screenshot->copyFrom(*mainWindowSurface);
|
|
|
|
runtime->setSaveScreenshotOverride(screenshot);
|
|
}
|
|
} else {
|
|
runtime->setSaveScreenshotOverride(Common::SharedPtr<Graphics::ManagedSurface>());
|
|
}
|
|
}
|
|
|
|
void addObsidianQuirks(const MTropolisGameDescription &desc, Hacks &hacks) {
|
|
// Add screenshot hook to store savegame screenshot prior to going to the menu
|
|
hacks.addSceneTransitionHooks(Common::SharedPtr<SceneTransitionHooks>(new ObsidianSaveScreenshotHooks()));
|
|
|
|
// Strip edit lists from QuickTime movies to work around audio popping problem.
|
|
// For some reason, some vidbots (like the cube maze entry bot) have edit lists
|
|
// in the audio track full of half-second edits, but the edit offsets are spaced
|
|
// 22080 samples apart instead of 22050, which causes it to skip 30 audio samples
|
|
// every half-second.
|
|
hacks.removeQuickTimeEdits = true;
|
|
}
|
|
|
|
void addObsidianBugFixes(const MTropolisGameDescription &desc, Hacks &hacks) {
|
|
// Workaround for bug in Obsidian:
|
|
// When opening the journal in the intro, a script checks if cGSt.cfst.binjournal is false and if so,
|
|
// sets cGSt.cfst.binjournal to true and then sets including setting cJournalConst.aksjournpath to the
|
|
// main journal scene path. That scene path is used to resolve the scene to go to after clicking
|
|
// the "Continue" button on the warning that pops up.
|
|
//
|
|
// The problem is that cJournalConst uses a project name that doesn't match the retail data, and
|
|
// cJournalConst is unloaded if the player leaves the journal. This causes a progression blocker if
|
|
// the player leaves the journal without clicking Continue.
|
|
hacks.ignoreMismatchedProjectNameInObjectLookups = true;
|
|
|
|
// Fix for corrupted frame in transition from the outer edge in Spider to the air puzzle tower.
|
|
// The data is corrupted in both Mac and Win retail versions.
|
|
hacks.addAssetHooks(Common::SharedPtr<AssetHooks>(new ObsidianCorruptedAirTowerTransitionFix()));
|
|
}
|
|
|
|
void addObsidianImprovedWidescreen(const MTropolisGameDescription &desc, Hacks &hacks) {
|
|
if ((desc.desc.flags & ADGF_DEMO) == 0 && desc.desc.language == Common::EN_ANY && desc.desc.platform == Common::kPlatformWindows) {
|
|
const uint32 inventoryItemGUIDs[] = {
|
|
// Bureau documents
|
|
// 100 area (booths)
|
|
0x4e2d9e,
|
|
0x4de654,
|
|
|
|
// 199 area (booths hint room)
|
|
0x4e2555,
|
|
0x4de654,
|
|
|
|
// 200 area (library)
|
|
0x4c83d2,
|
|
0x4c5802,
|
|
|
|
// 299 area (Cloud Ring)
|
|
0x178d5c,
|
|
0x177754,
|
|
|
|
// 300 area (light+phone)
|
|
0x4e0f86,
|
|
0x4e5107,
|
|
|
|
// 400 area (maze)
|
|
0x4e5528,
|
|
0x4e55cc,
|
|
|
|
// 500 area (Immediate Action)
|
|
0x4e2e7b,
|
|
0x4e0710,
|
|
|
|
// 800 area (bookshelves)
|
|
0x9914fb,
|
|
0x990f1f,
|
|
|
|
// 600 (sky face), 699 (mountain), and 700 (finale) have no document elements (player loses the documents)
|
|
|
|
// Bureau maze keycards
|
|
0x6035f,
|
|
0x62e24,
|
|
0x58d7f,
|
|
0x58212,
|
|
|
|
// Spider metal puzzle beaker
|
|
0x12fa7,
|
|
|
|
// Inspiration chip
|
|
// 100 area (Junkyard)
|
|
0x5f02e6,
|
|
|
|
// 200 area (Plane)
|
|
0x9bd5fc,
|
|
|
|
// 300 area (Piazza)
|
|
0x5ef979,
|
|
|
|
// 400 area (Church)
|
|
0xed9a8f,
|
|
|
|
// 500 area (Statue)
|
|
0x5ecdee,
|
|
};
|
|
|
|
const uint32 cubeMazeSecurityFormGUID = 0x9602ec;
|
|
const uint32 rsgIntroMovieGUID = 0x2fc101;
|
|
|
|
Common::SharedPtr<StructuralHooks> invItemHooks(new ObsidianInventoryWindscreenHooks());
|
|
|
|
for (uint32 guid : inventoryItemGUIDs)
|
|
hacks.addStructuralHooks(guid, invItemHooks);
|
|
|
|
hacks.addStructuralHooks(cubeMazeSecurityFormGUID, Common::SharedPtr<StructuralHooks>(new ObsidianSecurityFormWidescreenHooks()));
|
|
hacks.addStructuralHooks(rsgIntroMovieGUID, Common::SharedPtr<StructuralHooks>(new ObsidianRSGLogoWidescreenHooks()));
|
|
}
|
|
if ((desc.desc.flags & ADGF_DEMO) == 0 && desc.desc.language == Common::EN_ANY && desc.desc.platform == Common::kPlatformMacintosh) {
|
|
const uint32 inventoryItemGUIDs[] = {
|
|
// Bureau documents
|
|
// 100 area (booths)
|
|
0x4dfa22,
|
|
0x4e2d9e,
|
|
|
|
// 199 area (booths hint room)
|
|
0x4e2555,
|
|
0x4de654,
|
|
|
|
// 200 area (library)
|
|
0x4c83d2,
|
|
0x4c5802,
|
|
|
|
// 299 area (Cloud Ring)
|
|
0x178d5c,
|
|
0x177754,
|
|
|
|
// 300 area (light+phone)
|
|
0x4e0f86,
|
|
0x4e5107,
|
|
|
|
// 400 area (maze)
|
|
0x4e5528,
|
|
0x4e55cc,
|
|
|
|
// 500 area (Immediate Action)
|
|
0x4a2e7b,
|
|
0x4e0710,
|
|
|
|
// 800 area (bookshelves)
|
|
0x9914fb,
|
|
0x990f1f,
|
|
|
|
// 600 (sky face), 699 (mountain), and 700 (finale) have no document elements (player loses the documents)
|
|
|
|
// Bureau maze keycards
|
|
0x6035f,
|
|
0x62e24,
|
|
0x58d7f,
|
|
0x58212,
|
|
|
|
// Spider metal puzzle beaker
|
|
0x12fa7,
|
|
|
|
// Inspiration chip
|
|
// 100 area (Junkyard)
|
|
0x5f02e6,
|
|
|
|
// 200 area (Plane)
|
|
0x9bd5fc,
|
|
|
|
// 300 area (Piazza)
|
|
0x5ef979,
|
|
|
|
// 400 area (Church)
|
|
0xed9a8f,
|
|
|
|
// 500 area (Statue)
|
|
0x5ecdee,
|
|
};
|
|
|
|
const uint32 cubeMazeSecurityFormGUID = 0x9602ec;
|
|
const uint32 rsgIntroMovieGUID = 0x2fc101;
|
|
|
|
Common::SharedPtr<StructuralHooks> invItemHooks(new ObsidianInventoryWindscreenHooks());
|
|
|
|
for (uint32 guid : inventoryItemGUIDs)
|
|
hacks.addStructuralHooks(guid, invItemHooks);
|
|
|
|
hacks.addStructuralHooks(cubeMazeSecurityFormGUID, Common::SharedPtr<StructuralHooks>(new ObsidianSecurityFormWidescreenHooks()));
|
|
hacks.addStructuralHooks(rsgIntroMovieGUID, Common::SharedPtr<StructuralHooks>(new ObsidianRSGLogoWidescreenHooks()));
|
|
}
|
|
}
|
|
|
|
// Auto-save triggers for Obsidian. Basically, we auto-save on reaching specific scenes when conditions are met.
|
|
// There are two types of condition: One is the player reaches the scene from a one-way scene that can not be
|
|
// revisited, such as a chapter transition.
|
|
//
|
|
// The other is a variable latch, which happens if the variable became true since the last time the reset scene
|
|
// (the opening credits) was reached, or since the last time the game was loaded.
|
|
//
|
|
// Variable latches don't work if the latch becomes true and the player saves+reloads their game before they hit
|
|
// the auto-save checkpoint, but that's okay.
|
|
|
|
struct ObsidianAutoSaveTrigger {
|
|
const char *sceneName;
|
|
const char *priorSceneName; // If set, only save when transitioning from this scene
|
|
const char *varTrueLatch; // If set, only save when this variable was set to
|
|
};
|
|
|
|
static ObsidianAutoSaveTrigger kObsidianAutoSaveTriggers[] = {
|
|
// Arrive at campsite
|
|
{
|
|
"103.2L",
|
|
"102_103_Credits",
|
|
nullptr,
|
|
},
|
|
// Bureau start
|
|
{
|
|
"102.0L",
|
|
nullptr,
|
|
"cgst.clst.cl100st.bbossspoken"
|
|
},
|
|
// Win cube maze from side room
|
|
{
|
|
"445.2L",
|
|
nullptr,
|
|
"cgst.clst.cl400st.bcubiclewon",
|
|
},
|
|
// Win cube maze from back room
|
|
{
|
|
"445.0L",
|
|
nullptr,
|
|
"cgst.clst.cl400st.bcubiclewon",
|
|
},
|
|
// Stamp document and back away
|
|
{
|
|
"445.2L",
|
|
nullptr,
|
|
"cgst.clst.cinv.bstampedsd",
|
|
},
|
|
// Get repair document from cabinet + back away (English version)
|
|
{
|
|
"218.4L",
|
|
nullptr,
|
|
"cgst.clst.cinv.bhavesd",
|
|
},
|
|
// Get repair document from Bridge Repair + back away (non-English versions)
|
|
{
|
|
"109.6L",
|
|
nullptr,
|
|
"cgst.clst.cinv.bhavesd",
|
|
},
|
|
// Fix Immediate Action clock + exit left side
|
|
{
|
|
"504.0L",
|
|
nullptr,
|
|
"cgst.clst.cl500st.bwonl505",
|
|
},
|
|
// Fix Immediate Action clock + exit right side
|
|
{
|
|
"504.4L",
|
|
nullptr,
|
|
"cgst.clst.cl500st.bwonl505",
|
|
},
|
|
// Give document to Immediate Action + return to light
|
|
{
|
|
"306.6L",
|
|
nullptr,
|
|
"cgst.clst.cinv.bgavesd",
|
|
},
|
|
// Get rebel document + leave cabinet (English version)
|
|
{
|
|
"227.4L",
|
|
nullptr,
|
|
"cgst.clst.cinv.bhaveom",
|
|
},
|
|
// Solve dial puzzle
|
|
{
|
|
"699.0L",
|
|
nullptr,
|
|
"cgst.clst.cl308st.bwon",
|
|
},
|
|
|
|
// Spider start
|
|
{
|
|
"101.2L",
|
|
"710.0L",
|
|
nullptr,
|
|
},
|
|
// Leave elevator after completing any Spider puzzle
|
|
{
|
|
"121.0L",
|
|
"118.0L_121.0L-s1",
|
|
nullptr,
|
|
},
|
|
|
|
// Inspiration start
|
|
{
|
|
"101.4L",
|
|
"121.0L_Lunch_Time",
|
|
nullptr,
|
|
},
|
|
// Complete propulsion puzzle and leave engine room
|
|
{
|
|
"201.4L",
|
|
nullptr,
|
|
"cgst.cbst.cb2st.bengineon",
|
|
},
|
|
// Complete Church puzzle and leave robot
|
|
{
|
|
"412.4L",
|
|
nullptr,
|
|
"cgst.cbst.cb4st.bwon",
|
|
},
|
|
// Complete statue canvas puzzle
|
|
{
|
|
"523.2L_from_521.2L-s2",
|
|
nullptr,
|
|
"cgst.cbst.cb5st.bpaintedblank",
|
|
},
|
|
|
|
// Conductor start
|
|
{
|
|
"101.6L",
|
|
"203.4L_WIN",
|
|
nullptr,
|
|
},
|
|
// Freed Max
|
|
{
|
|
"104.0L_Max_Freed",
|
|
nullptr,
|
|
"cgst.ccst.cc1st.bmaxfreed",
|
|
},
|
|
};
|
|
|
|
class ObsidianAutoSaveVarsState {
|
|
public:
|
|
ObsidianAutoSaveVarsState();
|
|
|
|
static const VariableModifier *findVar(Runtime *runtime, const Common::String &str);
|
|
|
|
bool getVarState(const Common::String &varName) const;
|
|
void resyncAllVars(Runtime *runtime);
|
|
|
|
private:
|
|
Common::HashMap<Common::String, bool> _varState;
|
|
};
|
|
|
|
ObsidianAutoSaveVarsState::ObsidianAutoSaveVarsState() {
|
|
for (const ObsidianAutoSaveTrigger &trigger : kObsidianAutoSaveTriggers) {
|
|
if (trigger.varTrueLatch)
|
|
_varState[trigger.varTrueLatch] = false;
|
|
}
|
|
}
|
|
|
|
const VariableModifier *ObsidianAutoSaveVarsState::findVar(Runtime *runtime, const Common::String &str) {
|
|
size_t scanStartPos = 0;
|
|
|
|
const Modifier *modifierScan = nullptr;
|
|
const IModifierContainer *container = runtime->getProject();
|
|
|
|
for (;;) {
|
|
size_t dotPos = str.findFirstOf('.', scanStartPos);
|
|
if (dotPos == Common::String::npos)
|
|
dotPos = str.size();
|
|
|
|
Common::String childName = str.substr(scanStartPos, dotPos - scanStartPos);
|
|
if (!container)
|
|
return nullptr;
|
|
|
|
modifierScan = nullptr;
|
|
for (const Common::SharedPtr<Modifier> &modifier : container->getModifiers()) {
|
|
if (caseInsensitiveEqual(childName, modifier->getName())) {
|
|
modifierScan = modifier.get();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!modifierScan)
|
|
return nullptr;
|
|
|
|
if (modifierScan->isCompoundVariable())
|
|
container = static_cast<const CompoundVariableModifier *>(modifierScan);
|
|
|
|
if (dotPos == str.size())
|
|
break;
|
|
|
|
scanStartPos = dotPos + 1;
|
|
}
|
|
|
|
if (modifierScan && modifierScan->isVariable())
|
|
return static_cast<const VariableModifier *>(modifierScan);
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
bool ObsidianAutoSaveVarsState::getVarState(const Common::String &varName) const {
|
|
Common::HashMap<Common::String, bool>::const_iterator it = _varState.find(varName);
|
|
if (it == _varState.end())
|
|
return false;
|
|
return it->_value;
|
|
}
|
|
|
|
void ObsidianAutoSaveVarsState::resyncAllVars(Runtime *runtime) {
|
|
for (Common::HashMap<Common::String, bool>::iterator it = _varState.begin(), itEnd = _varState.end(); it != itEnd; ++it) {
|
|
const VariableModifier *var = findVar(runtime, it->_key);
|
|
if (var) {
|
|
DynamicValue varValue;
|
|
var->varGetValue(varValue);
|
|
assert(varValue.getType() == DynamicValueTypes::kBoolean);
|
|
|
|
it->_value = varValue.getBool();
|
|
}
|
|
}
|
|
}
|
|
|
|
class ObsidianAutoSaveSceneTransitionHooks : public SceneTransitionHooks {
|
|
public:
|
|
explicit ObsidianAutoSaveSceneTransitionHooks(const Common::SharedPtr<ObsidianAutoSaveVarsState> &vars, IAutoSaveProvider *autoSaveProvider);
|
|
|
|
void onSceneTransitionEnded(Runtime *runtime, const Common::WeakPtr<Structural> &newScene) override;
|
|
|
|
private:
|
|
Common::SharedPtr<ObsidianAutoSaveVarsState> _varsState;
|
|
IAutoSaveProvider *_autoSaveProvider;
|
|
|
|
Common::String _currentSceneName;
|
|
Common::String _prevSceneName;
|
|
|
|
Common::String _resetSceneName;
|
|
Common::String _saveVarName;
|
|
};
|
|
|
|
ObsidianAutoSaveSceneTransitionHooks::ObsidianAutoSaveSceneTransitionHooks(const Common::SharedPtr<ObsidianAutoSaveVarsState> &vars, IAutoSaveProvider *autoSaveProvider)
|
|
: _varsState(vars), _autoSaveProvider(autoSaveProvider) {
|
|
|
|
_resetSceneName = Common::String("101_102_Credits");
|
|
_saveVarName = Common::String("cgst");
|
|
}
|
|
|
|
void ObsidianAutoSaveSceneTransitionHooks::onSceneTransitionEnded(Runtime *runtime, const Common::WeakPtr<Structural> &newScene) {
|
|
bool triggerAutoSave = false;
|
|
|
|
if (newScene.expired())
|
|
return;
|
|
|
|
_prevSceneName = _currentSceneName;
|
|
_currentSceneName = newScene.lock()->getName();
|
|
|
|
for (const ObsidianAutoSaveTrigger &trigger : kObsidianAutoSaveTriggers) {
|
|
Common::String triggerSceneName(trigger.sceneName);
|
|
|
|
if (!caseInsensitiveEqual(triggerSceneName, _currentSceneName))
|
|
continue;
|
|
|
|
if (trigger.priorSceneName && !caseInsensitiveEqual(trigger.priorSceneName, _prevSceneName))
|
|
continue;
|
|
|
|
if (trigger.varTrueLatch) {
|
|
Common::String varName(trigger.varTrueLatch);
|
|
|
|
// Variable must must have been false since the last game load or reset
|
|
if (_varsState->getVarState(varName))
|
|
continue;
|
|
|
|
bool passedLatchTest = false;
|
|
|
|
const VariableModifier *var = _varsState->findVar(runtime, varName);
|
|
if (var) {
|
|
DynamicValue varValue;
|
|
var->varGetValue(varValue);
|
|
assert(varValue.getType() == DynamicValueTypes::kBoolean);
|
|
|
|
passedLatchTest = varValue.getBool();
|
|
}
|
|
|
|
if (!passedLatchTest)
|
|
continue;
|
|
}
|
|
|
|
triggerAutoSave = true;
|
|
break;
|
|
}
|
|
|
|
if (triggerAutoSave) {
|
|
Common::SharedPtr<Modifier> saveVar;
|
|
|
|
for (const Common::SharedPtr<Modifier> &child : runtime->getProject()->getModifiers()) {
|
|
if (caseInsensitiveEqual(child->getName(), _saveVarName)) {
|
|
saveVar = child;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (saveVar && saveVar->isModifier()) {
|
|
Modifier *modifier = static_cast<Modifier *>(saveVar.get());
|
|
Common::SharedPtr<ModifierSaveLoad> saveLoad = modifier->getSaveLoad();
|
|
|
|
if (saveLoad) {
|
|
CompoundVarSaver saver(saveVar.get());
|
|
_autoSaveProvider->autoSave(&saver);
|
|
|
|
_varsState->resyncAllVars(runtime);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (caseInsensitiveEqual(_currentSceneName, _resetSceneName))
|
|
_varsState->resyncAllVars(runtime);
|
|
}
|
|
|
|
class ObsidianAutoSaveSaveLoadHooks : public SaveLoadHooks {
|
|
public:
|
|
explicit ObsidianAutoSaveSaveLoadHooks(const Common::SharedPtr<ObsidianAutoSaveVarsState> &vars);
|
|
|
|
void onSave(Runtime *runtime, Modifier *saveLoadModifier, Modifier *varModifier) override;
|
|
void onLoad(Runtime *runtime, Modifier *saveLoadModifier, Modifier *varModifier) override;
|
|
|
|
private:
|
|
Common::SharedPtr<ObsidianAutoSaveVarsState> _varsState;
|
|
};
|
|
|
|
|
|
ObsidianAutoSaveSaveLoadHooks::ObsidianAutoSaveSaveLoadHooks(const Common::SharedPtr<ObsidianAutoSaveVarsState> &vars) : _varsState(vars) {
|
|
}
|
|
|
|
void ObsidianAutoSaveSaveLoadHooks::onSave(Runtime *runtime, Modifier *saveLoadModifier, Modifier *varModifier) {
|
|
// Reset all variable latches on save
|
|
_varsState->resyncAllVars(runtime);
|
|
}
|
|
|
|
void ObsidianAutoSaveSaveLoadHooks::onLoad(Runtime *runtime, Modifier *saveLoadModifier, Modifier *varModifier) {
|
|
// Reset all variable latches on load
|
|
_varsState->resyncAllVars(runtime);
|
|
}
|
|
|
|
void addObsidianAutoSaves(const MTropolisGameDescription &desc, Hacks &hacks, IAutoSaveProvider *autoSaveProvider) {
|
|
Common::SharedPtr<ObsidianAutoSaveVarsState> varsState(new ObsidianAutoSaveVarsState());
|
|
hacks.addSceneTransitionHooks(Common::SharedPtr<SceneTransitionHooks>(new ObsidianAutoSaveSceneTransitionHooks(varsState, autoSaveProvider)));
|
|
hacks.addSaveLoadHooks(Common::SharedPtr<SaveLoadHooks>(new ObsidianAutoSaveSaveLoadHooks(varsState)));
|
|
}
|
|
|
|
class ObsidianSaveLoadMechanism : public SaveLoadMechanismHooks {
|
|
public:
|
|
bool canSaveNow(Runtime *runtime) override;
|
|
Common::SharedPtr<ISaveWriter> createSaveWriter(Runtime *runtime) override;
|
|
};
|
|
|
|
bool ObsidianSaveLoadMechanism::canSaveNow(Runtime *runtime) {
|
|
Project *project = runtime->getProject();
|
|
|
|
// Check that we're in a game section
|
|
Structural *mainScene = runtime->getActiveMainScene().get();
|
|
|
|
if (!mainScene)
|
|
return false;
|
|
|
|
const Common::String disallowedSections[] = {
|
|
Common::String("Start Obsidian"), // Intro videos/screens
|
|
Common::String("End Obsidian"), // Credits
|
|
Common::String("GUI"), // Menus
|
|
};
|
|
|
|
Common::String sectionName = mainScene->getParent()->getParent()->getName();
|
|
|
|
for (const Common::String &disallowedSection : disallowedSections) {
|
|
if (caseInsensitiveEqual(disallowedSection, sectionName))
|
|
return false;
|
|
}
|
|
|
|
// Check that the g.bESC flag is set, meaning we can go to the menu
|
|
Common::String gName("g");
|
|
Common::String bEscName("bESC");
|
|
|
|
Modifier *gCompoundVar = nullptr;
|
|
for (const Common::SharedPtr<Modifier> &child : project->getModifiers()) {
|
|
if (caseInsensitiveEqual(child->getName(), gName)) {
|
|
gCompoundVar = child.get();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!gCompoundVar)
|
|
return false;
|
|
|
|
IModifierContainer *container = gCompoundVar->getChildContainer();
|
|
if (!container)
|
|
return false;
|
|
|
|
Modifier *bEscVar = nullptr;
|
|
for (const Common::SharedPtr<Modifier> &child : container->getModifiers()) {
|
|
if (caseInsensitiveEqual(child->getName(), bEscName)) {
|
|
bEscVar = child.get();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!bEscVar || !bEscVar->isVariable())
|
|
return false;
|
|
|
|
DynamicValue bEscValue;
|
|
static_cast<VariableModifier *>(bEscVar)->varGetValue(bEscValue);
|
|
|
|
if (bEscValue.getType() != DynamicValueTypes::kBoolean || !bEscValue.getBool())
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
Common::SharedPtr<ISaveWriter> ObsidianSaveLoadMechanism::createSaveWriter(Runtime *runtime) {
|
|
Project *project = runtime->getProject();
|
|
|
|
Common::String cgstName("cGSt");
|
|
|
|
Modifier *cgstCompoundVar = nullptr;
|
|
for (const Common::SharedPtr<Modifier> &child : project->getModifiers()) {
|
|
if (caseInsensitiveEqual(child->getName(), cgstName)) {
|
|
cgstCompoundVar = child.get();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!cgstCompoundVar)
|
|
return nullptr;
|
|
|
|
if (cgstCompoundVar->getSaveLoad())
|
|
return Common::SharedPtr<CompoundVarSaver>(new CompoundVarSaver(cgstCompoundVar));
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
void addObsidianSaveMechanism(const MTropolisGameDescription &desc, Hacks &hacks) {
|
|
Common::SharedPtr<ObsidianSaveLoadMechanism> mechanism(new ObsidianSaveLoadMechanism());
|
|
hacks.addSaveLoadMechanismHooks(mechanism);
|
|
}
|
|
|
|
void addMTIQuirks(const MTropolisGameDescription &desc, Hacks &hacks) {
|
|
// MTI uses a lot of "maintain rate" mToons at 10Hz. This means their frame timer resets on every frame advance, and
|
|
// is supposed to ensure that the mToon plays back at a smooth rate regardless of clock jitter. Unfortunately, it
|
|
// does this with mToons that are synchronized to sounds, which is bad! Presumably the reason this wasn't a problem
|
|
// is because MacOS runs with a 60Hz tick clock so it always divides evenly into the frame rate, and Windows... not sure.
|
|
//
|
|
// Anyway, there are two possible solutions to this: Lock the clock to 60Hz, or ignore the flag.
|
|
// Given that the flag should not be set, we ignore the flag.
|
|
hacks.ignoreMToonMaintainRateFlag = true;
|
|
|
|
// MTI initializes variables in a way that doesn't seem to match mTropolis behavior in any explicable way:
|
|
//
|
|
// For example, 0010cb0e "Scene Started => init Benbow" looks like this internally, decompiled:
|
|
// set local:a.billystate to 0
|
|
//
|
|
// In this case "a" is a compound variable and "billyState" is a NON-ALIASED integer variable contained in
|
|
// the compound. Later, 0009fc9a "Scene Started => play intro" checks local 00007f83 00 'billyState'
|
|
// to determine if the Benbow intro needs to be played. Since the GUID doesn't match (?) we check by name,
|
|
// which resolves to the GUID-less (?) alias in the Benbow subsection, which references 00097cf4, a different
|
|
// variable also named "billyState"
|
|
//
|
|
// Haven't figured out anything that would explain why it would reference the variables in the compound
|
|
// modifier. Probably some quirk of early-version mTropolis.
|
|
hacks.mtiVariableReferencesHack = true;
|
|
|
|
// MTI returns from the menu by transitioning to a "return" scene that sends a return message to the target
|
|
// scene, which is supposed to activate a scene transtion modifier in the scene that transitions to itself.
|
|
// This doesn't work because the modifier is gone when the scene is unloaded.
|
|
hacks.mtiSceneReturnHack = true;
|
|
}
|
|
|
|
} // End of namespace HackSuites
|
|
|
|
} // End of namespace MTropolis
|